mirror of
https://github.com/documenso/documenso.git
synced 2026-07-24 08:54:20 +10:00
feat: add envelopes (#2025)
This PR is handles the changes required to support envelopes. The new envelope editor/signing page will be hidden during release. The core changes here is to migrate the documents and templates model to a centralized envelopes model. Even though Documents and Templates are removed, from the user perspective they will still exist as we remap envelopes to documents and templates.
This commit is contained in:
+562
-385
File diff suppressed because it is too large
Load Diff
@@ -8,8 +8,8 @@ import {
|
||||
RecipientRole,
|
||||
SendStatus,
|
||||
SigningStatus,
|
||||
TemplateType,
|
||||
} from '@prisma/client';
|
||||
import { TemplateType } from '@prisma/client';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { DATE_FORMATS, DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
|
||||
@@ -49,7 +49,6 @@ export const ZSuccessfulDocumentResponseSchema = z.object({
|
||||
teamId: z.number().nullish(),
|
||||
title: z.string(),
|
||||
status: z.string(),
|
||||
documentDataId: z.string(),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date(),
|
||||
completedAt: z.date().nullable(),
|
||||
@@ -545,7 +544,6 @@ export const ZTemplateSchema = z.object({
|
||||
title: z.string(),
|
||||
userId: z.number(),
|
||||
teamId: z.number().nullish(),
|
||||
templateDocumentDataId: z.string(),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date(),
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import { expect, test } from '@playwright/test';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
|
||||
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { seedPendingDocumentWithFullFields } from '@documenso/prisma/seed/documents';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
@@ -25,7 +26,7 @@ test.describe('Document API', () => {
|
||||
|
||||
// Test with sendCompletionEmails: false
|
||||
const response = await request.post(
|
||||
`${NEXT_PUBLIC_WEBAPP_URL()}/api/v1/documents/${document.id}/send`,
|
||||
`${NEXT_PUBLIC_WEBAPP_URL()}/api/v1/documents/${mapSecondaryIdToDocumentId(document.secondaryId)}/send`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
@@ -41,7 +42,7 @@ test.describe('Document API', () => {
|
||||
expect(response.status()).toBe(200);
|
||||
|
||||
// Verify email settings were updated
|
||||
const updatedDocument = await prisma.document.findUnique({
|
||||
const updatedDocument = await prisma.envelope.findUnique({
|
||||
where: { id: document.id },
|
||||
include: { documentMeta: true },
|
||||
});
|
||||
@@ -53,7 +54,7 @@ test.describe('Document API', () => {
|
||||
|
||||
// Test with sendCompletionEmails: true
|
||||
const response2 = await request.post(
|
||||
`${NEXT_PUBLIC_WEBAPP_URL()}/api/v1/documents/${document.id}/send`,
|
||||
`${NEXT_PUBLIC_WEBAPP_URL()}/api/v1/documents/${mapSecondaryIdToDocumentId(document.secondaryId)}/send`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
@@ -69,7 +70,7 @@ test.describe('Document API', () => {
|
||||
expect(response2.status()).toBe(200);
|
||||
|
||||
// Verify email settings were updated
|
||||
const updatedDocument2 = await prisma.document.findUnique({
|
||||
const updatedDocument2 = await prisma.envelope.findUnique({
|
||||
where: { id: document.id },
|
||||
include: { documentMeta: true },
|
||||
});
|
||||
@@ -93,16 +94,16 @@ test.describe('Document API', () => {
|
||||
|
||||
// Set initial email settings
|
||||
await prisma.documentMeta.upsert({
|
||||
where: { documentId: document.id },
|
||||
where: { id: document.documentMetaId },
|
||||
create: {
|
||||
documentId: document.id,
|
||||
id: document.documentMetaId,
|
||||
emailSettings: {
|
||||
documentCompleted: true,
|
||||
ownerDocumentCompleted: false,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
documentId: document.id,
|
||||
id: document.documentMetaId,
|
||||
emailSettings: {
|
||||
documentCompleted: true,
|
||||
ownerDocumentCompleted: false,
|
||||
@@ -118,7 +119,7 @@ test.describe('Document API', () => {
|
||||
});
|
||||
|
||||
const response = await request.post(
|
||||
`${NEXT_PUBLIC_WEBAPP_URL()}/api/v1/documents/${document.id}/send`,
|
||||
`${NEXT_PUBLIC_WEBAPP_URL()}/api/v1/documents/${mapSecondaryIdToDocumentId(document.secondaryId)}/send`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
@@ -134,7 +135,7 @@ test.describe('Document API', () => {
|
||||
expect(response.status()).toBe(200);
|
||||
|
||||
// Verify email settings were not modified
|
||||
const updatedDocument = await prisma.document.findUnique({
|
||||
const updatedDocument = await prisma.envelope.findUnique({
|
||||
where: { id: document.id },
|
||||
include: { documentMeta: true },
|
||||
});
|
||||
|
||||
@@ -3,6 +3,11 @@ import { expect, test } from '@playwright/test';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
|
||||
import type { TCheckboxFieldMeta, TRadioFieldMeta } from '@documenso/lib/types/field-meta';
|
||||
import {
|
||||
mapDocumentIdToSecondaryId,
|
||||
mapSecondaryIdToDocumentId,
|
||||
mapSecondaryIdToTemplateId,
|
||||
} from '@documenso/lib/utils/envelope';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { FieldType, RecipientRole } from '@documenso/prisma/client';
|
||||
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
|
||||
@@ -35,10 +40,12 @@ test.describe('Template Field Prefill API v1', () => {
|
||||
},
|
||||
});
|
||||
|
||||
const firstEnvelopeItem = template.envelopeItems[0];
|
||||
|
||||
// 4. Create a recipient for the template
|
||||
const recipient = await prisma.recipient.create({
|
||||
data: {
|
||||
templateId: template.id,
|
||||
envelopeId: template.id,
|
||||
email: 'recipient@example.com',
|
||||
name: 'Test Recipient',
|
||||
role: RecipientRole.SIGNER,
|
||||
@@ -53,7 +60,8 @@ test.describe('Template Field Prefill API v1', () => {
|
||||
// Add TEXT field
|
||||
const textField = await prisma.field.create({
|
||||
data: {
|
||||
templateId: template.id,
|
||||
envelopeId: template.id,
|
||||
envelopeItemId: firstEnvelopeItem.id,
|
||||
recipientId: recipient.id,
|
||||
type: FieldType.TEXT,
|
||||
page: 1,
|
||||
@@ -73,7 +81,8 @@ test.describe('Template Field Prefill API v1', () => {
|
||||
// Add NUMBER field
|
||||
const numberField = await prisma.field.create({
|
||||
data: {
|
||||
templateId: template.id,
|
||||
envelopeId: template.id,
|
||||
envelopeItemId: firstEnvelopeItem.id,
|
||||
recipientId: recipient.id,
|
||||
type: FieldType.NUMBER,
|
||||
page: 1,
|
||||
@@ -93,7 +102,8 @@ test.describe('Template Field Prefill API v1', () => {
|
||||
// Add RADIO field
|
||||
const radioField = await prisma.field.create({
|
||||
data: {
|
||||
templateId: template.id,
|
||||
envelopeId: template.id,
|
||||
envelopeItemId: firstEnvelopeItem.id,
|
||||
recipientId: recipient.id,
|
||||
type: FieldType.RADIO,
|
||||
page: 1,
|
||||
@@ -117,7 +127,8 @@ test.describe('Template Field Prefill API v1', () => {
|
||||
// Add CHECKBOX field
|
||||
const checkboxField = await prisma.field.create({
|
||||
data: {
|
||||
templateId: template.id,
|
||||
envelopeId: template.id,
|
||||
envelopeItemId: firstEnvelopeItem.id,
|
||||
recipientId: recipient.id,
|
||||
type: FieldType.CHECKBOX,
|
||||
page: 1,
|
||||
@@ -141,7 +152,8 @@ test.describe('Template Field Prefill API v1', () => {
|
||||
// Add DROPDOWN field
|
||||
const dropdownField = await prisma.field.create({
|
||||
data: {
|
||||
templateId: template.id,
|
||||
envelopeId: template.id,
|
||||
envelopeItemId: firstEnvelopeItem.id,
|
||||
recipientId: recipient.id,
|
||||
type: FieldType.DROPDOWN,
|
||||
page: 1,
|
||||
@@ -166,11 +178,13 @@ test.describe('Template Field Prefill API v1', () => {
|
||||
});
|
||||
|
||||
// 7. Navigate to the template
|
||||
await page.goto(`${WEBAPP_BASE_URL}/templates/${template.id}`);
|
||||
await page.goto(
|
||||
`${WEBAPP_BASE_URL}/templates/${mapSecondaryIdToTemplateId(template.secondaryId)}`,
|
||||
);
|
||||
|
||||
// 8. Create a document from the template with prefilled fields
|
||||
const response = await request.post(
|
||||
`${WEBAPP_BASE_URL}/api/v1/templates/${template.id}/generate-document`,
|
||||
`${WEBAPP_BASE_URL}/api/v1/templates/${mapSecondaryIdToTemplateId(template.secondaryId)}/generate-document`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
@@ -229,9 +243,9 @@ test.describe('Template Field Prefill API v1', () => {
|
||||
expect(responseData.documentId).toBeDefined();
|
||||
|
||||
// 9. Verify the document was created with prefilled fields
|
||||
const document = await prisma.document.findUnique({
|
||||
const document = await prisma.envelope.findUnique({
|
||||
where: {
|
||||
id: responseData.documentId,
|
||||
secondaryId: mapDocumentIdToSecondaryId(responseData.documentId),
|
||||
},
|
||||
include: {
|
||||
fields: true,
|
||||
@@ -240,6 +254,10 @@ test.describe('Template Field Prefill API v1', () => {
|
||||
|
||||
expect(document).not.toBeNull();
|
||||
|
||||
if (!document) {
|
||||
throw new Error('Document not found');
|
||||
}
|
||||
|
||||
// 10. Verify each field has the correct prefilled values
|
||||
const documentTextField = document?.fields.find(
|
||||
(field) => field.type === FieldType.TEXT && field.fieldMeta?.type === 'text',
|
||||
@@ -297,14 +315,14 @@ test.describe('Template Field Prefill API v1', () => {
|
||||
// 11. Sign in as the recipient and verify the prefilled fields are visible
|
||||
const documentRecipient = await prisma.recipient.findFirst({
|
||||
where: {
|
||||
documentId: document?.id,
|
||||
envelopeId: document?.id,
|
||||
email: 'recipient@example.com',
|
||||
},
|
||||
});
|
||||
|
||||
// Send the document to the recipient
|
||||
const sendResponse = await request.post(
|
||||
`${WEBAPP_BASE_URL}/api/v1/documents/${document?.id}/send`,
|
||||
`${WEBAPP_BASE_URL}/api/v1/documents/${mapSecondaryIdToDocumentId(document.secondaryId)}/send`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
@@ -367,10 +385,12 @@ test.describe('Template Field Prefill API v1', () => {
|
||||
},
|
||||
});
|
||||
|
||||
const firstEnvelopeItem = template.envelopeItems[0];
|
||||
|
||||
// 4. Create a recipient for the template
|
||||
const recipient = await prisma.recipient.create({
|
||||
data: {
|
||||
templateId: template.id,
|
||||
envelopeId: template.id,
|
||||
email: 'recipient@example.com',
|
||||
name: 'Test Recipient',
|
||||
role: RecipientRole.SIGNER,
|
||||
@@ -385,7 +405,8 @@ test.describe('Template Field Prefill API v1', () => {
|
||||
// Add TEXT field
|
||||
await prisma.field.create({
|
||||
data: {
|
||||
templateId: template.id,
|
||||
envelopeId: template.id,
|
||||
envelopeItemId: firstEnvelopeItem.id,
|
||||
recipientId: recipient.id,
|
||||
type: FieldType.TEXT,
|
||||
page: 1,
|
||||
@@ -405,7 +426,8 @@ test.describe('Template Field Prefill API v1', () => {
|
||||
// Add NUMBER field
|
||||
await prisma.field.create({
|
||||
data: {
|
||||
templateId: template.id,
|
||||
envelopeId: template.id,
|
||||
envelopeItemId: firstEnvelopeItem.id,
|
||||
recipientId: recipient.id,
|
||||
type: FieldType.NUMBER,
|
||||
page: 1,
|
||||
@@ -429,11 +451,13 @@ test.describe('Template Field Prefill API v1', () => {
|
||||
});
|
||||
|
||||
// 7. Navigate to the template
|
||||
await page.goto(`${WEBAPP_BASE_URL}/templates/${template.id}`);
|
||||
await page.goto(
|
||||
`${WEBAPP_BASE_URL}/templates/${mapSecondaryIdToTemplateId(template.secondaryId)}`,
|
||||
);
|
||||
|
||||
// 8. Create a document from the template without prefilled fields
|
||||
const response = await request.post(
|
||||
`${WEBAPP_BASE_URL}/api/v1/templates/${template.id}/generate-document`,
|
||||
`${WEBAPP_BASE_URL}/api/v1/templates/${mapSecondaryIdToTemplateId(template.secondaryId)}/generate-document`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
@@ -461,9 +485,9 @@ test.describe('Template Field Prefill API v1', () => {
|
||||
expect(responseData.documentId).toBeDefined();
|
||||
|
||||
// 9. Verify the document was created with default fields
|
||||
const document = await prisma.document.findUnique({
|
||||
const document = await prisma.envelope.findUnique({
|
||||
where: {
|
||||
id: responseData.documentId,
|
||||
secondaryId: mapDocumentIdToSecondaryId(responseData.documentId),
|
||||
},
|
||||
include: {
|
||||
fields: true,
|
||||
@@ -472,6 +496,10 @@ test.describe('Template Field Prefill API v1', () => {
|
||||
|
||||
expect(document).not.toBeNull();
|
||||
|
||||
if (!document) {
|
||||
throw new Error('Document not found');
|
||||
}
|
||||
|
||||
// 10. Verify fields have their default values
|
||||
const documentTextField = document?.fields.find((field) => field.type === FieldType.TEXT);
|
||||
expect(documentTextField?.fieldMeta).toMatchObject({
|
||||
@@ -488,7 +516,7 @@ test.describe('Template Field Prefill API v1', () => {
|
||||
// 11. Sign in as the recipient and verify the default fields are visible
|
||||
const documentRecipient = await prisma.recipient.findFirst({
|
||||
where: {
|
||||
documentId: document?.id,
|
||||
envelopeId: document?.id,
|
||||
email: 'recipient@example.com',
|
||||
},
|
||||
});
|
||||
@@ -496,7 +524,7 @@ test.describe('Template Field Prefill API v1', () => {
|
||||
expect(documentRecipient).not.toBeNull();
|
||||
|
||||
const sendResponse = await request.post(
|
||||
`${WEBAPP_BASE_URL}/api/v1/documents/${document?.id}/send`,
|
||||
`${WEBAPP_BASE_URL}/api/v1/documents/${mapSecondaryIdToDocumentId(document.secondaryId)}/send`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
@@ -539,10 +567,12 @@ test.describe('Template Field Prefill API v1', () => {
|
||||
},
|
||||
});
|
||||
|
||||
const firstEnvelopeItem = template.envelopeItems[0];
|
||||
|
||||
// 4. Create a recipient for the template
|
||||
const recipient = await prisma.recipient.create({
|
||||
data: {
|
||||
templateId: template.id,
|
||||
envelopeId: template.id,
|
||||
email: 'recipient@example.com',
|
||||
name: 'Test Recipient',
|
||||
role: RecipientRole.SIGNER,
|
||||
@@ -556,7 +586,8 @@ test.describe('Template Field Prefill API v1', () => {
|
||||
// 5. Add a field to the template
|
||||
const field = await prisma.field.create({
|
||||
data: {
|
||||
templateId: template.id,
|
||||
envelopeId: template.id,
|
||||
envelopeItemId: firstEnvelopeItem.id,
|
||||
recipientId: recipient.id,
|
||||
type: FieldType.RADIO,
|
||||
page: 1,
|
||||
@@ -579,7 +610,7 @@ test.describe('Template Field Prefill API v1', () => {
|
||||
|
||||
// 6. Try to create a document with invalid prefill value
|
||||
const response = await request.post(
|
||||
`${WEBAPP_BASE_URL}/api/v1/templates/${template.id}/generate-document`,
|
||||
`${WEBAPP_BASE_URL}/api/v1/templates/${mapSecondaryIdToTemplateId(template.secondaryId)}/generate-document`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,11 @@ import { expect, test } from '@playwright/test';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
|
||||
import type { TCheckboxFieldMeta, TRadioFieldMeta } from '@documenso/lib/types/field-meta';
|
||||
import {
|
||||
mapDocumentIdToSecondaryId,
|
||||
mapSecondaryIdToDocumentId,
|
||||
mapSecondaryIdToTemplateId,
|
||||
} from '@documenso/lib/utils/envelope';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { FieldType, RecipientRole } from '@documenso/prisma/client';
|
||||
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
|
||||
@@ -35,10 +40,12 @@ test.describe('Template Field Prefill API v2', () => {
|
||||
},
|
||||
});
|
||||
|
||||
const firstEnvelopeItem = template.envelopeItems[0];
|
||||
|
||||
// 4. Create a recipient for the template
|
||||
const recipient = await prisma.recipient.create({
|
||||
data: {
|
||||
templateId: template.id,
|
||||
envelopeId: template.id,
|
||||
email: 'recipient@example.com',
|
||||
name: 'Test Recipient',
|
||||
role: RecipientRole.SIGNER,
|
||||
@@ -53,7 +60,8 @@ test.describe('Template Field Prefill API v2', () => {
|
||||
// Add TEXT field
|
||||
const textField = await prisma.field.create({
|
||||
data: {
|
||||
templateId: template.id,
|
||||
envelopeId: template.id,
|
||||
envelopeItemId: firstEnvelopeItem.id,
|
||||
recipientId: recipient.id,
|
||||
type: FieldType.TEXT,
|
||||
page: 1,
|
||||
@@ -73,7 +81,8 @@ test.describe('Template Field Prefill API v2', () => {
|
||||
// Add NUMBER field
|
||||
const numberField = await prisma.field.create({
|
||||
data: {
|
||||
templateId: template.id,
|
||||
envelopeId: template.id,
|
||||
envelopeItemId: firstEnvelopeItem.id,
|
||||
recipientId: recipient.id,
|
||||
type: FieldType.NUMBER,
|
||||
page: 1,
|
||||
@@ -93,7 +102,8 @@ test.describe('Template Field Prefill API v2', () => {
|
||||
// Add RADIO field
|
||||
const radioField = await prisma.field.create({
|
||||
data: {
|
||||
templateId: template.id,
|
||||
envelopeId: template.id,
|
||||
envelopeItemId: firstEnvelopeItem.id,
|
||||
recipientId: recipient.id,
|
||||
type: FieldType.RADIO,
|
||||
page: 1,
|
||||
@@ -117,7 +127,8 @@ test.describe('Template Field Prefill API v2', () => {
|
||||
// Add CHECKBOX field
|
||||
const checkboxField = await prisma.field.create({
|
||||
data: {
|
||||
templateId: template.id,
|
||||
envelopeId: template.id,
|
||||
envelopeItemId: firstEnvelopeItem.id,
|
||||
recipientId: recipient.id,
|
||||
type: FieldType.CHECKBOX,
|
||||
page: 1,
|
||||
@@ -141,7 +152,8 @@ test.describe('Template Field Prefill API v2', () => {
|
||||
// Add DROPDOWN field
|
||||
const dropdownField = await prisma.field.create({
|
||||
data: {
|
||||
templateId: template.id,
|
||||
envelopeId: template.id,
|
||||
envelopeItemId: firstEnvelopeItem.id,
|
||||
recipientId: recipient.id,
|
||||
type: FieldType.DROPDOWN,
|
||||
page: 1,
|
||||
@@ -166,7 +178,9 @@ test.describe('Template Field Prefill API v2', () => {
|
||||
});
|
||||
|
||||
// 7. Navigate to the template
|
||||
await page.goto(`${WEBAPP_BASE_URL}/templates/${template.id}`);
|
||||
await page.goto(
|
||||
`${WEBAPP_BASE_URL}/templates/${mapSecondaryIdToTemplateId(template.secondaryId)}`,
|
||||
);
|
||||
|
||||
// 8. Create a document from the template with prefilled fields using v2 API
|
||||
const response = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/template/use`, {
|
||||
@@ -175,7 +189,7 @@ test.describe('Template Field Prefill API v2', () => {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
data: {
|
||||
templateId: template.id,
|
||||
templateId: mapSecondaryIdToTemplateId(template.secondaryId),
|
||||
recipients: [
|
||||
{
|
||||
id: recipient.id,
|
||||
@@ -226,9 +240,9 @@ test.describe('Template Field Prefill API v2', () => {
|
||||
expect(responseData.id).toBeDefined();
|
||||
|
||||
// 9. Verify the document was created with prefilled fields
|
||||
const document = await prisma.document.findUnique({
|
||||
const document = await prisma.envelope.findUnique({
|
||||
where: {
|
||||
id: responseData.id,
|
||||
secondaryId: mapDocumentIdToSecondaryId(responseData.id),
|
||||
},
|
||||
include: {
|
||||
fields: true,
|
||||
@@ -237,6 +251,10 @@ test.describe('Template Field Prefill API v2', () => {
|
||||
|
||||
expect(document).not.toBeNull();
|
||||
|
||||
if (!document) {
|
||||
throw new Error('Document not found');
|
||||
}
|
||||
|
||||
// 10. Verify each field has the correct prefilled values
|
||||
const documentTextField = document?.fields.find(
|
||||
(field) => field.type === FieldType.TEXT && field.fieldMeta?.type === 'text',
|
||||
@@ -297,7 +315,7 @@ test.describe('Template Field Prefill API v2', () => {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
data: {
|
||||
documentId: document?.id,
|
||||
documentId: mapSecondaryIdToDocumentId(document?.secondaryId),
|
||||
meta: {
|
||||
subject: 'Test Subject',
|
||||
message: 'Test Message',
|
||||
@@ -311,7 +329,7 @@ test.describe('Template Field Prefill API v2', () => {
|
||||
// 11. Sign in as the recipient and verify the prefilled fields are visible
|
||||
const documentRecipient = await prisma.recipient.findFirst({
|
||||
where: {
|
||||
documentId: document?.id,
|
||||
envelopeId: document?.id,
|
||||
email: 'recipient@example.com',
|
||||
},
|
||||
});
|
||||
@@ -364,10 +382,12 @@ test.describe('Template Field Prefill API v2', () => {
|
||||
},
|
||||
});
|
||||
|
||||
const firstEnvelopeItem = template.envelopeItems[0];
|
||||
|
||||
// 4. Create a recipient for the template
|
||||
const recipient = await prisma.recipient.create({
|
||||
data: {
|
||||
templateId: template.id,
|
||||
envelopeId: template.id,
|
||||
email: 'recipient@example.com',
|
||||
name: 'Test Recipient',
|
||||
role: RecipientRole.SIGNER,
|
||||
@@ -382,7 +402,8 @@ test.describe('Template Field Prefill API v2', () => {
|
||||
// Add TEXT field
|
||||
await prisma.field.create({
|
||||
data: {
|
||||
templateId: template.id,
|
||||
envelopeId: template.id,
|
||||
envelopeItemId: firstEnvelopeItem.id,
|
||||
recipientId: recipient.id,
|
||||
type: FieldType.TEXT,
|
||||
page: 1,
|
||||
@@ -402,7 +423,8 @@ test.describe('Template Field Prefill API v2', () => {
|
||||
// Add NUMBER field
|
||||
await prisma.field.create({
|
||||
data: {
|
||||
templateId: template.id,
|
||||
envelopeId: template.id,
|
||||
envelopeItemId: firstEnvelopeItem.id,
|
||||
recipientId: recipient.id,
|
||||
type: FieldType.NUMBER,
|
||||
page: 1,
|
||||
@@ -426,7 +448,9 @@ test.describe('Template Field Prefill API v2', () => {
|
||||
});
|
||||
|
||||
// 7. Navigate to the template
|
||||
await page.goto(`${WEBAPP_BASE_URL}/templates/${template.id}`);
|
||||
await page.goto(
|
||||
`${WEBAPP_BASE_URL}/templates/${mapSecondaryIdToTemplateId(template.secondaryId)}`,
|
||||
);
|
||||
|
||||
// 8. Create a document from the template without prefilled fields using v2 API
|
||||
const response = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/template/use`, {
|
||||
@@ -435,7 +459,7 @@ test.describe('Template Field Prefill API v2', () => {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
data: {
|
||||
templateId: template.id,
|
||||
templateId: mapSecondaryIdToTemplateId(template.secondaryId),
|
||||
recipients: [
|
||||
{
|
||||
id: recipient.id,
|
||||
@@ -454,9 +478,9 @@ test.describe('Template Field Prefill API v2', () => {
|
||||
expect(responseData.id).toBeDefined();
|
||||
|
||||
// 9. Verify the document was created with default fields
|
||||
const document = await prisma.document.findUnique({
|
||||
const document = await prisma.envelope.findUnique({
|
||||
where: {
|
||||
id: responseData.id,
|
||||
secondaryId: mapDocumentIdToSecondaryId(responseData.id),
|
||||
},
|
||||
include: {
|
||||
fields: true,
|
||||
@@ -465,6 +489,10 @@ test.describe('Template Field Prefill API v2', () => {
|
||||
|
||||
expect(document).not.toBeNull();
|
||||
|
||||
if (!document) {
|
||||
throw new Error('Document not found');
|
||||
}
|
||||
|
||||
// 10. Verify fields have their default values
|
||||
const documentTextField = document?.fields.find((field) => field.type === FieldType.TEXT);
|
||||
expect(documentTextField?.fieldMeta).toMatchObject({
|
||||
@@ -484,7 +512,7 @@ test.describe('Template Field Prefill API v2', () => {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
data: {
|
||||
documentId: document?.id,
|
||||
documentId: mapSecondaryIdToDocumentId(document?.secondaryId),
|
||||
meta: {
|
||||
subject: 'Test Subject',
|
||||
message: 'Test Message',
|
||||
@@ -498,7 +526,7 @@ test.describe('Template Field Prefill API v2', () => {
|
||||
// 11. Sign in as the recipient and verify the default fields are visible
|
||||
const documentRecipient = await prisma.recipient.findFirst({
|
||||
where: {
|
||||
documentId: document?.id,
|
||||
envelopeId: document?.id,
|
||||
email: 'recipient@example.com',
|
||||
},
|
||||
});
|
||||
@@ -531,10 +559,12 @@ test.describe('Template Field Prefill API v2', () => {
|
||||
},
|
||||
});
|
||||
|
||||
const firstEnvelopeItem = template.envelopeItems[0];
|
||||
|
||||
// 4. Create a recipient for the template
|
||||
const recipient = await prisma.recipient.create({
|
||||
data: {
|
||||
templateId: template.id,
|
||||
envelopeId: template.id,
|
||||
email: 'recipient@example.com',
|
||||
name: 'Test Recipient',
|
||||
role: RecipientRole.SIGNER,
|
||||
@@ -548,7 +578,8 @@ test.describe('Template Field Prefill API v2', () => {
|
||||
// 5. Add a field to the template
|
||||
const field = await prisma.field.create({
|
||||
data: {
|
||||
templateId: template.id,
|
||||
envelopeId: template.id,
|
||||
envelopeItemId: firstEnvelopeItem.id,
|
||||
recipientId: recipient.id,
|
||||
type: FieldType.RADIO,
|
||||
page: 1,
|
||||
@@ -576,7 +607,7 @@ test.describe('Template Field Prefill API v2', () => {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
data: {
|
||||
templateId: template.id,
|
||||
templateId: mapSecondaryIdToTemplateId(template.secondaryId),
|
||||
recipients: [
|
||||
{
|
||||
id: recipient.id,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -19,7 +19,7 @@ test('[DOCUMENT_AUTH]: should grant access when not required', async ({ page })
|
||||
|
||||
const recipients = await prisma.recipient.findMany({
|
||||
where: {
|
||||
documentId: document.id,
|
||||
envelopeId: document.id,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -52,7 +52,7 @@ test('[DOCUMENT_AUTH]: should allow or deny access when required', async ({ page
|
||||
|
||||
const recipients = await prisma.recipient.findMany({
|
||||
where: {
|
||||
documentId: document.id,
|
||||
envelopeId: document.id,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -85,7 +85,7 @@ test('[NEXT_RECIPIENT_DICTATION]: should allow updating next recipient when dict
|
||||
await page.waitForURL(`${signUrl}/complete`);
|
||||
|
||||
// Verify document and recipient states
|
||||
const updatedDocument = await prisma.document.findUniqueOrThrow({
|
||||
const updatedDocument = await prisma.envelope.findUniqueOrThrow({
|
||||
where: { id: document.id },
|
||||
include: {
|
||||
recipients: {
|
||||
@@ -172,7 +172,7 @@ test('[NEXT_RECIPIENT_DICTATION]: should not show dictation UI when disabled', a
|
||||
|
||||
// Verify document and recipient states
|
||||
|
||||
const updatedDocument = await prisma.document.findUniqueOrThrow({
|
||||
const updatedDocument = await prisma.envelope.findUniqueOrThrow({
|
||||
where: { id: document.id },
|
||||
include: {
|
||||
recipients: {
|
||||
@@ -259,7 +259,7 @@ test('[NEXT_RECIPIENT_DICTATION]: should work with parallel signing flow', async
|
||||
|
||||
// Verify final document and recipient states
|
||||
await expect(async () => {
|
||||
const updatedDocument = await prisma.document.findUniqueOrThrow({
|
||||
const updatedDocument = await prisma.envelope.findUniqueOrThrow({
|
||||
where: { id: document.id },
|
||||
include: {
|
||||
recipients: {
|
||||
@@ -295,19 +295,13 @@ test('[NEXT_RECIPIENT_DICTATION]: should allow assistant to dictate next signer'
|
||||
{ signingOrder: 2, role: RecipientRole.SIGNER },
|
||||
{ signingOrder: 3, role: RecipientRole.SIGNER },
|
||||
],
|
||||
updateDocumentOptions: {
|
||||
documentMeta: {
|
||||
upsert: {
|
||||
create: {
|
||||
allowDictateNextSigner: true,
|
||||
signingOrder: DocumentSigningOrder.SEQUENTIAL,
|
||||
},
|
||||
update: {
|
||||
allowDictateNextSigner: true,
|
||||
signingOrder: DocumentSigningOrder.SEQUENTIAL,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.documentMeta.update({
|
||||
where: { id: document.documentMetaId },
|
||||
data: {
|
||||
allowDictateNextSigner: true,
|
||||
signingOrder: DocumentSigningOrder.SEQUENTIAL,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -362,7 +356,7 @@ test('[NEXT_RECIPIENT_DICTATION]: should allow assistant to dictate next signer'
|
||||
|
||||
// Verify document and recipient states
|
||||
await expect(async () => {
|
||||
const updatedDocument = await prisma.document.findUniqueOrThrow({
|
||||
const updatedDocument = await prisma.envelope.findUniqueOrThrow({
|
||||
where: { id: document.id },
|
||||
include: {
|
||||
recipients: {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { getFieldsForDocument } from '@documenso/lib/server-only/field/get-fields-for-document';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
|
||||
@@ -14,7 +14,7 @@ const setupDocumentAndNavigateToFieldsStep = async (page: Page) => {
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/documents/${document.id}/edit`,
|
||||
redirectPath: `/t/${team.url}/documents/${document.id}/edit`,
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
@@ -84,10 +84,8 @@ test.describe('AutoSave Fields Step', () => {
|
||||
await triggerAutosave(page);
|
||||
|
||||
await expect(async () => {
|
||||
const retrievedFields = await getFieldsForDocument({
|
||||
documentId: document.id,
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
const retrievedFields = await getFieldsForEnvelope({
|
||||
envelopeId: document.id,
|
||||
});
|
||||
|
||||
expect(retrievedFields.length).toBe(3);
|
||||
@@ -149,10 +147,8 @@ test.describe('AutoSave Fields Step', () => {
|
||||
await triggerAutosave(page);
|
||||
|
||||
await expect(async () => {
|
||||
const retrievedFields = await getFieldsForDocument({
|
||||
documentId: document.id,
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
const retrievedFields = await getFieldsForEnvelope({
|
||||
envelopeId: document.id,
|
||||
});
|
||||
|
||||
expect(retrievedFields.length).toBe(2);
|
||||
@@ -213,10 +209,8 @@ test.describe('AutoSave Fields Step', () => {
|
||||
await triggerAutosave(page);
|
||||
|
||||
await expect(async () => {
|
||||
const retrievedFields = await getFieldsForDocument({
|
||||
documentId: document.id,
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
const retrievedFields = await getFieldsForEnvelope({
|
||||
envelopeId: document.id,
|
||||
});
|
||||
|
||||
expect(retrievedFields.length).toBe(4);
|
||||
@@ -260,10 +254,8 @@ test.describe('AutoSave Fields Step', () => {
|
||||
await triggerAutosave(page);
|
||||
|
||||
await expect(async () => {
|
||||
const retrievedFields = await getFieldsForDocument({
|
||||
documentId: document.id,
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
const retrievedFields = await getFieldsForEnvelope({
|
||||
envelopeId: document.id,
|
||||
});
|
||||
|
||||
expect(retrievedFields.length).toBe(2);
|
||||
@@ -291,3 +283,28 @@ test.describe('AutoSave Fields Step', () => {
|
||||
}).toPass();
|
||||
});
|
||||
});
|
||||
|
||||
const getFieldsForEnvelope = async ({ envelopeId }: { envelopeId: string }) => {
|
||||
const fields = await prisma.field.findMany({
|
||||
where: {
|
||||
envelope: {
|
||||
id: envelopeId,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
signature: true,
|
||||
recipient: {
|
||||
select: {
|
||||
name: true,
|
||||
email: true,
|
||||
signingStatus: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
id: 'asc',
|
||||
},
|
||||
});
|
||||
|
||||
return fields;
|
||||
};
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { EnvelopeType } from '@prisma/client';
|
||||
|
||||
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
|
||||
import { getEnvelopeById } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
|
||||
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
|
||||
@@ -17,7 +18,7 @@ const setupDocument = async (page: Page) => {
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/documents/${document.id}/edit`,
|
||||
redirectPath: `/t/${team.url}/documents/${document.id}/edit`,
|
||||
});
|
||||
|
||||
return { user, team, document };
|
||||
@@ -41,8 +42,12 @@ test.describe('AutoSave Settings Step', () => {
|
||||
await triggerAutosave(page);
|
||||
|
||||
await expect(async () => {
|
||||
const retrieved = await getDocumentById({
|
||||
documentId: document.id,
|
||||
const retrieved = await getEnvelopeById({
|
||||
id: {
|
||||
type: 'envelopeId',
|
||||
id: document.id,
|
||||
},
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
@@ -63,8 +68,12 @@ test.describe('AutoSave Settings Step', () => {
|
||||
await triggerAutosave(page);
|
||||
|
||||
await expect(async () => {
|
||||
const retrieved = await getDocumentById({
|
||||
documentId: document.id,
|
||||
const retrieved = await getEnvelopeById({
|
||||
id: {
|
||||
type: 'envelopeId',
|
||||
id: document.id,
|
||||
},
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
@@ -85,8 +94,12 @@ test.describe('AutoSave Settings Step', () => {
|
||||
await triggerAutosave(page);
|
||||
|
||||
await expect(async () => {
|
||||
const retrieved = await getDocumentById({
|
||||
documentId: document.id,
|
||||
const retrieved = await getEnvelopeById({
|
||||
id: {
|
||||
type: 'envelopeId',
|
||||
id: document.id,
|
||||
},
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
@@ -107,8 +120,12 @@ test.describe('AutoSave Settings Step', () => {
|
||||
await triggerAutosave(page);
|
||||
|
||||
await expect(async () => {
|
||||
const retrieved = await getDocumentById({
|
||||
documentId: document.id,
|
||||
const retrieved = await getEnvelopeById({
|
||||
id: {
|
||||
type: 'envelopeId',
|
||||
id: document.id,
|
||||
},
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
@@ -129,8 +146,12 @@ test.describe('AutoSave Settings Step', () => {
|
||||
await triggerAutosave(page);
|
||||
|
||||
await expect(async () => {
|
||||
const retrieved = await getDocumentById({
|
||||
documentId: document.id,
|
||||
const retrieved = await getEnvelopeById({
|
||||
id: {
|
||||
type: 'envelopeId',
|
||||
id: document.id,
|
||||
},
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
@@ -152,8 +173,12 @@ test.describe('AutoSave Settings Step', () => {
|
||||
await triggerAutosave(page);
|
||||
|
||||
await expect(async () => {
|
||||
const retrieved = await getDocumentById({
|
||||
documentId: document.id,
|
||||
const retrieved = await getEnvelopeById({
|
||||
id: {
|
||||
type: 'envelopeId',
|
||||
id: document.id,
|
||||
},
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
@@ -173,8 +198,12 @@ test.describe('AutoSave Settings Step', () => {
|
||||
await triggerAutosave(page);
|
||||
|
||||
await expect(async () => {
|
||||
const retrieved = await getDocumentById({
|
||||
documentId: document.id,
|
||||
const retrieved = await getEnvelopeById({
|
||||
id: {
|
||||
type: 'envelopeId',
|
||||
id: document.id,
|
||||
},
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
@@ -195,8 +224,12 @@ test.describe('AutoSave Settings Step', () => {
|
||||
await triggerAutosave(page);
|
||||
|
||||
await expect(async () => {
|
||||
const retrieved = await getDocumentById({
|
||||
documentId: document.id,
|
||||
const retrieved = await getEnvelopeById({
|
||||
id: {
|
||||
type: 'envelopeId',
|
||||
id: document.id,
|
||||
},
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
@@ -227,8 +260,12 @@ test.describe('AutoSave Settings Step', () => {
|
||||
await triggerAutosave(page);
|
||||
|
||||
await expect(async () => {
|
||||
const retrieved = await getDocumentById({
|
||||
documentId: document.id,
|
||||
const retrieved = await getEnvelopeById({
|
||||
id: {
|
||||
type: 'envelopeId',
|
||||
id: document.id,
|
||||
},
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { EnvelopeType } from '@prisma/client';
|
||||
|
||||
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
|
||||
import { getEnvelopeById } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
|
||||
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
|
||||
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
|
||||
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
|
||||
@@ -17,7 +19,7 @@ const setupDocumentAndNavigateToSignersStep = async (page: Page) => {
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/documents/${document.id}/edit`,
|
||||
redirectPath: `/t/${team.url}/documents/${document.id}/edit`,
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
@@ -47,7 +49,7 @@ test.describe('AutoSave Signers Step', () => {
|
||||
|
||||
await expect(async () => {
|
||||
const retrievedRecipients = await getRecipientsForDocument({
|
||||
documentId: document.id,
|
||||
documentId: mapSecondaryIdToDocumentId(document.secondaryId),
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
@@ -71,7 +73,7 @@ test.describe('AutoSave Signers Step', () => {
|
||||
|
||||
await expect(async () => {
|
||||
const retrievedRecipients = await getRecipientsForDocument({
|
||||
documentId: document.id,
|
||||
documentId: mapSecondaryIdToDocumentId(document.secondaryId),
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
@@ -99,7 +101,7 @@ test.describe('AutoSave Signers Step', () => {
|
||||
|
||||
await expect(async () => {
|
||||
const retrievedRecipients = await getRecipientsForDocument({
|
||||
documentId: document.id,
|
||||
documentId: mapSecondaryIdToDocumentId(document.secondaryId),
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
@@ -145,14 +147,18 @@ test.describe('AutoSave Signers Step', () => {
|
||||
await triggerAutosave(page);
|
||||
|
||||
await expect(async () => {
|
||||
const retrievedDocumentData = await getDocumentById({
|
||||
documentId: document.id,
|
||||
const retrievedDocumentData = await getEnvelopeById({
|
||||
id: {
|
||||
type: 'envelopeId',
|
||||
id: document.id,
|
||||
},
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
const retrievedRecipients = await getRecipientsForDocument({
|
||||
documentId: document.id,
|
||||
documentId: mapSecondaryIdToDocumentId(document.secondaryId),
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { EnvelopeType } from '@prisma/client';
|
||||
|
||||
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
|
||||
import { getEnvelopeById } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
|
||||
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
|
||||
@@ -16,7 +17,7 @@ export const setupDocumentAndNavigateToSubjectStep = async (page: Page) => {
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/documents/${document.id}/edit`,
|
||||
redirectPath: `/t/${team.url}/documents/${document.id}/edit`,
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
@@ -59,8 +60,12 @@ test.describe('AutoSave Subject Step', () => {
|
||||
await triggerAutosave(page);
|
||||
|
||||
await expect(async () => {
|
||||
const retrievedDocumentData = await getDocumentById({
|
||||
documentId: document.id,
|
||||
const retrievedDocumentData = await getEnvelopeById({
|
||||
id: {
|
||||
type: 'envelopeId',
|
||||
id: document.id,
|
||||
},
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
@@ -81,8 +86,12 @@ test.describe('AutoSave Subject Step', () => {
|
||||
await triggerAutosave(page);
|
||||
|
||||
await expect(async () => {
|
||||
const retrievedDocumentData = await getDocumentById({
|
||||
documentId: document.id,
|
||||
const retrievedDocumentData = await getEnvelopeById({
|
||||
id: {
|
||||
type: 'envelopeId',
|
||||
id: document.id,
|
||||
},
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
@@ -105,8 +114,12 @@ test.describe('AutoSave Subject Step', () => {
|
||||
await triggerAutosave(page);
|
||||
|
||||
await expect(async () => {
|
||||
const retrievedDocumentData = await getDocumentById({
|
||||
documentId: document.id,
|
||||
const retrievedDocumentData = await getEnvelopeById({
|
||||
id: {
|
||||
type: 'envelopeId',
|
||||
id: document.id,
|
||||
},
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
@@ -156,8 +169,12 @@ test.describe('AutoSave Subject Step', () => {
|
||||
await triggerAutosave(page);
|
||||
|
||||
await expect(async () => {
|
||||
const retrievedDocumentData = await getDocumentById({
|
||||
documentId: document.id,
|
||||
const retrievedDocumentData = await getEnvelopeById({
|
||||
id: {
|
||||
type: 'envelopeId',
|
||||
id: document.id,
|
||||
},
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
@@ -50,7 +50,7 @@ test('[DOCUMENT_FLOW]: Simple duplicate recipients test', async ({ page }) => {
|
||||
await page.waitForTimeout(2500);
|
||||
await page.getByRole('button', { name: 'Send' }).click();
|
||||
|
||||
await page.waitForURL(new RegExp(`/t/${team.url}/documents/\\d+`));
|
||||
await page.waitForURL(new RegExp(`/t/${team.url}/documents/envelope_.*`));
|
||||
|
||||
await expect(page.getByRole('link', { name: document.title })).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -69,7 +69,7 @@ const completeDocumentFlowWithDuplicateRecipients = async (options: {
|
||||
await page.getByRole('button', { name: 'Send' }).click();
|
||||
|
||||
// Wait for send confirmation
|
||||
await page.waitForURL(new RegExp(`/t/${team.url}/documents/\\d+`));
|
||||
await page.waitForURL(`/t/${team.url}/documents`);
|
||||
|
||||
await expect(page.getByRole('link', { name: document.title })).toBeVisible();
|
||||
};
|
||||
@@ -157,7 +157,7 @@ test.describe('[DOCUMENT_FLOW]: Duplicate Recipients', () => {
|
||||
await page.waitForTimeout(2500);
|
||||
await page.getByRole('button', { name: 'Send' }).click();
|
||||
|
||||
await page.waitForURL(new RegExp(`/t/${team.url}/documents/\\d+`));
|
||||
await page.waitForURL(new RegExp(`/t/${team.url}/documents/envelope_.*`));
|
||||
|
||||
await expect(page.getByRole('link', { name: document.title })).toBeVisible();
|
||||
});
|
||||
@@ -188,7 +188,7 @@ test.describe('[DOCUMENT_FLOW]: Duplicate Recipients', () => {
|
||||
|
||||
const recipients = await prisma.recipient.findMany({
|
||||
where: {
|
||||
documentId: document.id,
|
||||
envelopeId: document.id,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -286,7 +286,7 @@ test.describe('[DOCUMENT_FLOW]: Duplicate Recipients', () => {
|
||||
|
||||
await page.getByRole('button', { name: 'Send' }).click();
|
||||
|
||||
await page.waitForURL(new RegExp(`/t/${team.url}/documents/\\d+`));
|
||||
await page.waitForURL(new RegExp(`/t/${team.url}/documents/envelope_.*`));
|
||||
|
||||
await expect(page.getByRole('link', { name: document.title })).toBeVisible();
|
||||
});
|
||||
@@ -348,7 +348,7 @@ test.describe('[DOCUMENT_FLOW]: Duplicate Recipients', () => {
|
||||
await page.waitForTimeout(2500);
|
||||
await page.getByRole('button', { name: 'Send' }).click();
|
||||
|
||||
await page.waitForURL(new RegExp(`/t/${team.url}/documents/\\d+`));
|
||||
await page.waitForURL(new RegExp(`/t/${team.url}/documents/envelope_.*`));
|
||||
|
||||
await expect(page.getByRole('link', { name: document.title })).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
import { DateTime } from 'luxon';
|
||||
import path from 'node:path';
|
||||
|
||||
import { getRecipientByEmail } from '@documenso/lib/server-only/recipient/get-recipient-by-email';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import {
|
||||
seedBlankDocument,
|
||||
@@ -23,7 +22,7 @@ import { signSignaturePad } from '../fixtures/signature';
|
||||
// Can't use the function in server-only/document due to it indirectly using
|
||||
// require imports.
|
||||
const getDocumentByToken = async (token: string) => {
|
||||
return await prisma.document.findFirstOrThrow({
|
||||
return await prisma.envelope.findFirstOrThrow({
|
||||
where: {
|
||||
recipients: {
|
||||
some: {
|
||||
@@ -59,7 +58,7 @@ test('[DOCUMENT_FLOW]: should be able to upload a PDF document', async ({ page }
|
||||
await fileChooser.setFiles(path.join(__dirname, '../../../../assets/example.pdf'));
|
||||
|
||||
// Wait to be redirected to the edit page.
|
||||
await page.waitForURL(new RegExp(`/t/${team.url}/documents/\\d+`));
|
||||
await page.waitForURL(new RegExp(`/t/${team.url}/documents/envelope_.*`));
|
||||
});
|
||||
|
||||
test('[DOCUMENT_FLOW]: should be able to create a document', async ({ page }) => {
|
||||
@@ -115,7 +114,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document', async ({ page }) =>
|
||||
await page.waitForTimeout(2500);
|
||||
await page.getByRole('button', { name: 'Send' }).click();
|
||||
|
||||
await page.waitForURL(new RegExp(`/t/${team.url}/documents/\\d+`));
|
||||
await page.waitForURL(new RegExp(`/t/${team.url}/documents/envelope_.*`));
|
||||
|
||||
// Assert document was created
|
||||
await expect(page.getByRole('link', { name: documentTitle })).toBeVisible();
|
||||
@@ -200,7 +199,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie
|
||||
await page.waitForTimeout(2500);
|
||||
await page.getByRole('button', { name: 'Send' }).click();
|
||||
|
||||
await page.waitForURL(new RegExp(`/t/${team.url}/documents/\\d+`));
|
||||
await page.waitForURL(new RegExp(`/t/${team.url}/documents/envelope_.*`));
|
||||
|
||||
// Assert document was created
|
||||
await expect(page.getByRole('link', { name: documentTitle })).toBeVisible();
|
||||
@@ -298,7 +297,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie
|
||||
await page.waitForTimeout(2500);
|
||||
await page.getByRole('button', { name: 'Send' }).click();
|
||||
|
||||
await page.waitForURL(new RegExp(`/t/${team.url}/documents/\\d+`));
|
||||
await page.waitForURL(new RegExp(`/t/${team.url}/documents/envelope_.*`));
|
||||
|
||||
// Assert document was created
|
||||
await expect(page.getByRole('link', { name: 'Test Title' })).toBeVisible();
|
||||
@@ -437,14 +436,18 @@ test('[DOCUMENT_FLOW]: should be able to create, send with redirect url, sign a
|
||||
// Assert document was created
|
||||
await expect(page.getByRole('link', { name: documentTitle })).toBeVisible();
|
||||
await page.getByRole('link', { name: documentTitle }).click();
|
||||
await page.waitForURL(new RegExp(`/t/${team.url}/documents/\\d+`));
|
||||
await page.waitForURL(new RegExp(`/t/${team.url}/documents/envelope_.*`));
|
||||
|
||||
const url = page.url().split('/');
|
||||
const documentId = url[url.length - 1];
|
||||
|
||||
const { token } = await getRecipientByEmail({
|
||||
email: 'user1@example.com',
|
||||
documentId: Number(documentId),
|
||||
const { token } = await prisma.recipient.findFirstOrThrow({
|
||||
where: {
|
||||
envelope: {
|
||||
id: documentId,
|
||||
},
|
||||
email: 'user1@example.com',
|
||||
},
|
||||
});
|
||||
|
||||
await page.goto(`/sign/${token}`);
|
||||
@@ -500,7 +503,7 @@ test('[DOCUMENT_FLOW]: should be able to sign a document with custom date', asyn
|
||||
recipient: {
|
||||
email: 'user1@example.com',
|
||||
},
|
||||
documentId: Number(document.id),
|
||||
envelopeId: document.id,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -583,11 +586,11 @@ test('[DOCUMENT_FLOW]: should be able to create and sign a document with 3 recip
|
||||
await page.waitForTimeout(2500);
|
||||
await page.getByRole('button', { name: 'Send' }).click();
|
||||
|
||||
await page.waitForURL(new RegExp(`/t/${team.url}/documents/\\d+`));
|
||||
await page.waitForURL(new RegExp(`/t/${team.url}/documents/envelope_.*`));
|
||||
|
||||
await expect(page.getByRole('link', { name: documentTitle })).toBeVisible();
|
||||
|
||||
const createdDocument = await prisma.document.findFirst({
|
||||
const createdDocument = await prisma.envelope.findFirst({
|
||||
where: { title: documentTitle },
|
||||
include: { recipients: true },
|
||||
});
|
||||
@@ -602,13 +605,13 @@ test('[DOCUMENT_FLOW]: should be able to create and sign a document with 3 recip
|
||||
expect(recipient).not.toBeNull();
|
||||
|
||||
const fields = await prisma.field.findMany({
|
||||
where: { recipientId: recipient?.id, documentId: createdDocument?.id },
|
||||
where: { recipientId: recipient?.id, envelopeId: createdDocument?.id },
|
||||
});
|
||||
const recipientField = fields[0];
|
||||
|
||||
if (i > 0) {
|
||||
const previousRecipient = await prisma.recipient.findFirst({
|
||||
where: { email: `user${i}@example.com`, documentId: createdDocument?.id },
|
||||
where: { email: `user${i}@example.com`, envelopeId: createdDocument?.id },
|
||||
});
|
||||
|
||||
expect(previousRecipient?.signingStatus).toBe(SigningStatus.SIGNED);
|
||||
@@ -636,7 +639,7 @@ test('[DOCUMENT_FLOW]: should be able to create and sign a document with 3 recip
|
||||
// Wait for the document to be signed.
|
||||
await page.waitForTimeout(10000);
|
||||
|
||||
const finalDocument = await prisma.document.findFirst({
|
||||
const finalDocument = await prisma.envelope.findFirst({
|
||||
where: { id: createdDocument?.id },
|
||||
});
|
||||
|
||||
@@ -648,18 +651,20 @@ test('[DOCUMENT_FLOW]: should prevent out-of-order signing in sequential mode',
|
||||
}) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const { recipients } = await seedPendingDocumentWithFullFields({
|
||||
const { document, recipients } = await seedPendingDocumentWithFullFields({
|
||||
teamId: team.id,
|
||||
owner: user,
|
||||
recipients: ['user1@example.com', 'user2@example.com', 'user3@example.com'],
|
||||
fields: [FieldType.SIGNATURE],
|
||||
recipientsCreateOptions: [{ signingOrder: 1 }, { signingOrder: 2 }, { signingOrder: 3 }],
|
||||
updateDocumentOptions: {
|
||||
documentMeta: {
|
||||
create: {
|
||||
signingOrder: DocumentSigningOrder.SEQUENTIAL,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.documentMeta.update({
|
||||
where: {
|
||||
id: document.documentMetaId,
|
||||
},
|
||||
data: {
|
||||
signingOrder: DocumentSigningOrder.SEQUENTIAL,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ test.describe('Unauthorized Access to Documents', () => {
|
||||
});
|
||||
|
||||
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/t/${team.url}/documents/${document.id}`);
|
||||
await expect(page.getByRole('heading', { name: 'Oops! Something went wrong.' })).toBeVisible();
|
||||
await expect(page.getByRole('heading', { name: 'Team not found' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('should block unauthorized access to the draft document edit page', async ({ page }) => {
|
||||
@@ -44,7 +44,7 @@ test.describe('Unauthorized Access to Documents', () => {
|
||||
});
|
||||
|
||||
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/t/${team.url}/documents/${document.id}/edit`);
|
||||
await expect(page.getByRole('heading', { name: 'Oops! Something went wrong.' })).toBeVisible();
|
||||
await expect(page.getByRole('heading', { name: 'Team not found' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('should block unauthorized access to the pending document page', async ({ page }) => {
|
||||
@@ -61,7 +61,7 @@ test.describe('Unauthorized Access to Documents', () => {
|
||||
});
|
||||
|
||||
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/t/${team.url}/documents/${document.id}`);
|
||||
await expect(page.getByRole('heading', { name: 'Oops! Something went wrong.' })).toBeVisible();
|
||||
await expect(page.getByRole('heading', { name: 'Team not found' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('should block unauthorized access to pending document edit page', async ({ page }) => {
|
||||
@@ -78,7 +78,7 @@ test.describe('Unauthorized Access to Documents', () => {
|
||||
});
|
||||
|
||||
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/t/${team.url}/documents/${document.id}/edit`);
|
||||
await expect(page.getByRole('heading', { name: 'Oops! Something went wrong.' })).toBeVisible();
|
||||
await expect(page.getByRole('heading', { name: 'Team not found' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('should block unauthorized access to completed document page', async ({ page }) => {
|
||||
@@ -95,6 +95,6 @@ test.describe('Unauthorized Access to Documents', () => {
|
||||
});
|
||||
|
||||
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/t/${team.url}/documents/${document.id}`);
|
||||
await expect(page.getByRole('heading', { name: 'Oops! Something went wrong.' })).toBeVisible();
|
||||
await expect(page.getByRole('heading', { name: 'Team not found' })).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { PDFDocument } from '@cantoo/pdf-lib';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { DocumentStatus, FieldType } from '@prisma/client';
|
||||
import { PDFDocument } from 'pdf-lib';
|
||||
|
||||
import { getDocumentByToken } from '@documenso/lib/server-only/document/get-document-by-token';
|
||||
import { getFile } from '@documenso/lib/universal/upload/get-file';
|
||||
@@ -28,7 +28,9 @@ test.describe('Signing Certificate Tests', () => {
|
||||
const documentData = await prisma.documentData
|
||||
.findFirstOrThrow({
|
||||
where: {
|
||||
id: document.documentDataId,
|
||||
envelopeItem: {
|
||||
envelopeId: document.id,
|
||||
},
|
||||
},
|
||||
})
|
||||
.then(async (data) => getFile(data));
|
||||
@@ -65,12 +67,21 @@ test.describe('Signing Certificate Tests', () => {
|
||||
await page.waitForTimeout(2500);
|
||||
|
||||
// Get the completed document
|
||||
const completedDocument = await prisma.document.findFirstOrThrow({
|
||||
const completedDocument = await prisma.envelope.findFirstOrThrow({
|
||||
where: { id: document.id },
|
||||
include: { documentData: true },
|
||||
include: {
|
||||
envelopeItems: {
|
||||
include: {
|
||||
documentData: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const completedDocumentData = await getFile(completedDocument.documentData);
|
||||
// Todo: Envelopes
|
||||
const firstDocumentData = completedDocument.envelopeItems[0].documentData;
|
||||
|
||||
const completedDocumentData = await getFile(firstDocumentData);
|
||||
|
||||
// Load the PDF and check number of pages
|
||||
const pdfDoc = await PDFDocument.load(completedDocumentData);
|
||||
@@ -110,7 +121,9 @@ test.describe('Signing Certificate Tests', () => {
|
||||
const documentData = await prisma.documentData
|
||||
.findFirstOrThrow({
|
||||
where: {
|
||||
id: document.documentDataId,
|
||||
envelopeItem: {
|
||||
envelopeId: document.id,
|
||||
},
|
||||
},
|
||||
})
|
||||
.then(async (data) => getFile(data));
|
||||
@@ -145,12 +158,21 @@ test.describe('Signing Certificate Tests', () => {
|
||||
await page.waitForTimeout(2500);
|
||||
|
||||
// Get the completed document
|
||||
const completedDocument = await prisma.document.findFirstOrThrow({
|
||||
const completedDocument = await prisma.envelope.findFirstOrThrow({
|
||||
where: { id: document.id },
|
||||
include: { documentData: true },
|
||||
include: {
|
||||
envelopeItems: {
|
||||
include: {
|
||||
documentData: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const completedDocumentData = await getFile(completedDocument.documentData);
|
||||
// Todo: Envelopes
|
||||
const firstDocumentData = completedDocument.envelopeItems[0].documentData;
|
||||
|
||||
const completedDocumentData = await getFile(firstDocumentData);
|
||||
|
||||
// Load the PDF and check number of pages
|
||||
const completedPdf = await PDFDocument.load(completedDocumentData);
|
||||
@@ -190,7 +212,9 @@ test.describe('Signing Certificate Tests', () => {
|
||||
const documentData = await prisma.documentData
|
||||
.findFirstOrThrow({
|
||||
where: {
|
||||
id: document.documentDataId,
|
||||
envelopeItem: {
|
||||
envelopeId: document.id,
|
||||
},
|
||||
},
|
||||
})
|
||||
.then(async (data) => getFile(data));
|
||||
@@ -225,12 +249,18 @@ test.describe('Signing Certificate Tests', () => {
|
||||
await page.waitForTimeout(2500);
|
||||
|
||||
// Get the completed document
|
||||
const completedDocument = await prisma.document.findFirstOrThrow({
|
||||
const completedDocument = await prisma.envelope.findFirstOrThrow({
|
||||
where: { id: document.id },
|
||||
include: { documentData: true },
|
||||
include: {
|
||||
envelopeItems: {
|
||||
include: {
|
||||
documentData: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const completedDocumentData = await getFile(completedDocument.documentData);
|
||||
const completedDocumentData = await getFile(completedDocument.envelopeItems[0].documentData);
|
||||
|
||||
// Load the PDF and check number of pages
|
||||
const completedPdf = await PDFDocument.load(completedDocumentData);
|
||||
|
||||
@@ -44,12 +44,6 @@ test('[TEAMS]: can create document folder', async ({ page }) => {
|
||||
test('[TEAMS]: can create document subfolder within a document folder', async ({ page }) => {
|
||||
const { team, teamOwner } = await seedTeamDocuments();
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: teamOwner.email,
|
||||
redirectPath: `/t/${team.url}`,
|
||||
});
|
||||
|
||||
const teamFolder = await seedBlankFolder(teamOwner, team.id, {
|
||||
createFolderOptions: {
|
||||
name: 'Team Folder',
|
||||
@@ -57,7 +51,11 @@ test('[TEAMS]: can create document subfolder within a document folder', async ({
|
||||
},
|
||||
});
|
||||
|
||||
await page.goto(`/t/${team.url}/documents/f/${teamFolder.id}`);
|
||||
await apiSignin({
|
||||
page,
|
||||
email: teamOwner.email,
|
||||
redirectPath: `/t/${team.url}/documents/f/${teamFolder.id}`,
|
||||
});
|
||||
|
||||
await page.getByTestId('folder-create-button').click();
|
||||
|
||||
@@ -383,12 +381,10 @@ test('[TEAMS]: can create a template inside a template folder', async ({ page })
|
||||
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
await page.getByRole('button', { name: 'Create' }).click();
|
||||
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Expect redirect.
|
||||
await expect(page.getByText('documenso-supporter-pledge.pdf')).toBeVisible();
|
||||
|
||||
// Return to folder and verify file is visible.
|
||||
await page.goto(`/t/${team.url}/templates/f/${folder.id}`);
|
||||
await expect(page.getByText('documenso-supporter-pledge.pdf')).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -96,7 +96,7 @@ test('[ORGANISATIONS]: manage document preferences', async ({ page }) => {
|
||||
|
||||
const documentMeta = await prisma.documentMeta.findFirstOrThrow({
|
||||
where: {
|
||||
documentId: document.id,
|
||||
id: document.documentMetaId,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -272,7 +272,7 @@ test('[ORGANISATIONS]: manage email preferences', async ({ page }) => {
|
||||
|
||||
const teamOverrideDocumentMeta = await prisma.documentMeta.findFirstOrThrow({
|
||||
where: {
|
||||
documentId: teamOverrideDocument.id,
|
||||
id: teamOverrideDocument.documentMetaId,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -317,7 +317,7 @@ test('[ORGANISATIONS]: manage email preferences', async ({ page }) => {
|
||||
|
||||
const documentMeta = await prisma.documentMeta.findFirstOrThrow({
|
||||
where: {
|
||||
documentId: document.id,
|
||||
id: document.documentMetaId,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -146,6 +146,7 @@ test('[TEAMS]: search does not reveal documents from other teams', async ({ page
|
||||
redirectPath: `/t/${teamA.url}/documents`,
|
||||
});
|
||||
|
||||
await page.waitForTimeout(100);
|
||||
await page.getByPlaceholder('Search documents...').fill('Unique');
|
||||
await page.waitForURL(/query=Unique/);
|
||||
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import {
|
||||
mapSecondaryIdToDocumentId,
|
||||
mapSecondaryIdToTemplateId,
|
||||
} from '@documenso/lib/utils/envelope';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import {
|
||||
seedTeamDocumentWithMeta,
|
||||
@@ -21,7 +25,9 @@ test('[TEAMS]: check that default team signature settings are all enabled', asyn
|
||||
const document = await seedTeamDocumentWithMeta(team);
|
||||
|
||||
// Create a document and check the settings
|
||||
await page.goto(`/t/${team.url}/documents/${document.id}/edit`);
|
||||
await page.goto(
|
||||
`/t/${team.url}/documents/${mapSecondaryIdToDocumentId(document.secondaryId)}/edit`,
|
||||
);
|
||||
|
||||
// Verify that the settings match
|
||||
await page.getByRole('button', { name: 'Advanced Options' }).click();
|
||||
@@ -154,7 +160,7 @@ test('[TEAMS]: check signature modes work for templates', async ({ page }) => {
|
||||
|
||||
const template = await seedTeamTemplateWithMeta(team);
|
||||
|
||||
await page.goto(`/t/${team.url}/templates/${template.id}`);
|
||||
await page.goto(`/t/${team.url}/templates/${mapSecondaryIdToTemplateId(template.secondaryId)}`);
|
||||
await page.getByRole('button', { name: 'Use' }).click();
|
||||
|
||||
// Check the send document checkbox to true
|
||||
@@ -162,9 +168,10 @@ test('[TEAMS]: check signature modes work for templates', async ({ page }) => {
|
||||
await page.getByRole('button', { name: 'Create and send' }).click();
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
const document = await prisma.document.findFirst({
|
||||
const document = await prisma.envelope.findFirst({
|
||||
where: {
|
||||
templateId: template.id,
|
||||
// Created from template
|
||||
templateId: mapSecondaryIdToTemplateId(template.secondaryId),
|
||||
},
|
||||
include: {
|
||||
documentMeta: true,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { type Page, expect, test } from '@playwright/test';
|
||||
import type { Team, Template } from '@prisma/client';
|
||||
import type { Envelope, Team } from '@prisma/client';
|
||||
|
||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
@@ -14,7 +14,7 @@ import { apiSignin } from '../fixtures/authentication';
|
||||
const completeTemplateFlowWithDuplicateRecipients = async (options: {
|
||||
page: Page;
|
||||
team: Team;
|
||||
template: Template;
|
||||
template: Envelope;
|
||||
}) => {
|
||||
const { page, team, template } = options;
|
||||
// Step 1: Settings - Continue with defaults
|
||||
@@ -131,20 +131,20 @@ test.describe('[TEMPLATE_FLOW]: Duplicate Recipients', () => {
|
||||
|
||||
// Create document
|
||||
await page.getByRole('button', { name: 'Create and send' }).click();
|
||||
await page.waitForURL(new RegExp(`/t/${team.url}/documents/\\d+`));
|
||||
await page.waitForURL(new RegExp(`/t/${team.url}/documents/envelope_.*`));
|
||||
|
||||
// Get the document ID from URL for database queries
|
||||
const url = page.url();
|
||||
const documentIdMatch = url.match(/\/documents\/(\d+)/);
|
||||
const documentIdMatch = url.match(/\/documents\/envelope_(.*)/);
|
||||
|
||||
const documentId = documentIdMatch ? parseInt(documentIdMatch[1]) : null;
|
||||
const envelopeId = documentIdMatch ? documentIdMatch[1] : null;
|
||||
|
||||
expect(documentId).not.toBeNull();
|
||||
expect(envelopeId).not.toBeNull();
|
||||
|
||||
// Get recipients directly from database
|
||||
const recipients = await prisma.recipient.findMany({
|
||||
where: {
|
||||
documentId: documentId!,
|
||||
envelopeId: `envelope_${envelopeId}`,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Page } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
|
||||
import { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope';
|
||||
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
|
||||
@@ -14,7 +15,7 @@ const setupTemplateAndNavigateToFieldsStep = async (page: Page) => {
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/templates/${template.id}/edit`,
|
||||
redirectPath: `/t/${team.url}/templates/${mapSecondaryIdToTemplateId(template.secondaryId)}/edit`,
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
@@ -85,7 +86,10 @@ test.describe('AutoSave Fields Step', () => {
|
||||
|
||||
await expect(async () => {
|
||||
const retrievedFields = await getTemplateById({
|
||||
id: template.id,
|
||||
id: {
|
||||
type: 'envelopeId',
|
||||
id: template.id,
|
||||
},
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
@@ -152,7 +156,10 @@ test.describe('AutoSave Fields Step', () => {
|
||||
|
||||
await expect(async () => {
|
||||
const retrievedFields = await getTemplateById({
|
||||
id: template.id,
|
||||
id: {
|
||||
type: 'envelopeId',
|
||||
id: template.id,
|
||||
},
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
@@ -218,7 +225,11 @@ test.describe('AutoSave Fields Step', () => {
|
||||
|
||||
await expect(async () => {
|
||||
const retrievedFields = await getTemplateById({
|
||||
id: template.id,
|
||||
id: {
|
||||
type: 'envelopeId',
|
||||
id: template.id,
|
||||
},
|
||||
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
@@ -270,7 +281,10 @@ test.describe('AutoSave Fields Step', () => {
|
||||
|
||||
await expect(async () => {
|
||||
const retrievedTemplate = await getTemplateById({
|
||||
id: template.id,
|
||||
id: {
|
||||
type: 'envelopeId',
|
||||
id: template.id,
|
||||
},
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Page } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
|
||||
import { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope';
|
||||
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
|
||||
@@ -16,7 +17,7 @@ const setupTemplate = async (page: Page) => {
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/templates/${template.id}/edit`,
|
||||
redirectPath: `/t/${team.url}/templates/${mapSecondaryIdToTemplateId(template.secondaryId)}/edit`,
|
||||
});
|
||||
|
||||
return { user, team, template };
|
||||
@@ -41,7 +42,10 @@ test.describe('AutoSave Settings Step - Templates', () => {
|
||||
|
||||
await expect(async () => {
|
||||
const retrievedTemplate = await getTemplateById({
|
||||
id: template.id,
|
||||
id: {
|
||||
type: 'envelopeId',
|
||||
id: template.id,
|
||||
},
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
@@ -65,7 +69,10 @@ test.describe('AutoSave Settings Step - Templates', () => {
|
||||
|
||||
await expect(async () => {
|
||||
const retrievedTemplate = await getTemplateById({
|
||||
id: template.id,
|
||||
id: {
|
||||
type: 'envelopeId',
|
||||
id: template.id,
|
||||
},
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
@@ -87,7 +94,10 @@ test.describe('AutoSave Settings Step - Templates', () => {
|
||||
|
||||
await expect(async () => {
|
||||
const retrievedTemplate = await getTemplateById({
|
||||
id: template.id,
|
||||
id: {
|
||||
type: 'envelopeId',
|
||||
id: template.id,
|
||||
},
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
@@ -109,7 +119,10 @@ test.describe('AutoSave Settings Step - Templates', () => {
|
||||
|
||||
await expect(async () => {
|
||||
const retrievedTemplate = await getTemplateById({
|
||||
id: template.id,
|
||||
id: {
|
||||
type: 'envelopeId',
|
||||
id: template.id,
|
||||
},
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
@@ -131,7 +144,10 @@ test.describe('AutoSave Settings Step - Templates', () => {
|
||||
|
||||
await expect(async () => {
|
||||
const retrievedTemplate = await getTemplateById({
|
||||
id: template.id,
|
||||
id: {
|
||||
type: 'envelopeId',
|
||||
id: template.id,
|
||||
},
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
@@ -154,7 +170,10 @@ test.describe('AutoSave Settings Step - Templates', () => {
|
||||
|
||||
await expect(async () => {
|
||||
const retrievedTemplate = await getTemplateById({
|
||||
id: template.id,
|
||||
id: {
|
||||
type: 'envelopeId',
|
||||
id: template.id,
|
||||
},
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
@@ -175,7 +194,10 @@ test.describe('AutoSave Settings Step - Templates', () => {
|
||||
|
||||
await expect(async () => {
|
||||
const retrievedTemplate = await getTemplateById({
|
||||
id: template.id,
|
||||
id: {
|
||||
type: 'envelopeId',
|
||||
id: template.id,
|
||||
},
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
@@ -197,7 +219,10 @@ test.describe('AutoSave Settings Step - Templates', () => {
|
||||
|
||||
await expect(async () => {
|
||||
const retrievedTemplate = await getTemplateById({
|
||||
id: template.id,
|
||||
id: {
|
||||
type: 'envelopeId',
|
||||
id: template.id,
|
||||
},
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
@@ -229,7 +254,10 @@ test.describe('AutoSave Settings Step - Templates', () => {
|
||||
|
||||
await expect(async () => {
|
||||
const retrievedTemplate = await getTemplateById({
|
||||
id: template.id,
|
||||
id: {
|
||||
type: 'envelopeId',
|
||||
id: template.id,
|
||||
},
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { EnvelopeType } from '@prisma/client';
|
||||
|
||||
import { getRecipientsForTemplate } from '@documenso/lib/server-only/recipient/get-recipients-for-template';
|
||||
import { getEnvelopeWhereInput } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
|
||||
import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
|
||||
import { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
|
||||
@@ -17,7 +20,7 @@ const setupTemplateAndNavigateToSignersStep = async (page: Page) => {
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/templates/${template.id}/edit`,
|
||||
redirectPath: `/t/${team.url}/templates/${mapSecondaryIdToTemplateId(template.secondaryId)}/edit`,
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
@@ -47,7 +50,7 @@ test.describe('AutoSave Signers Step - Templates', () => {
|
||||
|
||||
await expect(async () => {
|
||||
const retrievedRecipients = await getRecipientsForTemplate({
|
||||
templateId: template.id,
|
||||
templateId: mapSecondaryIdToTemplateId(template.secondaryId),
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
@@ -71,7 +74,7 @@ test.describe('AutoSave Signers Step - Templates', () => {
|
||||
|
||||
await expect(async () => {
|
||||
const retrievedRecipients = await getRecipientsForTemplate({
|
||||
templateId: template.id,
|
||||
templateId: mapSecondaryIdToTemplateId(template.secondaryId),
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
@@ -99,7 +102,7 @@ test.describe('AutoSave Signers Step - Templates', () => {
|
||||
|
||||
await expect(async () => {
|
||||
const retrievedRecipients = await getRecipientsForTemplate({
|
||||
templateId: template.id,
|
||||
templateId: mapSecondaryIdToTemplateId(template.secondaryId),
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
@@ -152,13 +155,16 @@ test.describe('AutoSave Signers Step - Templates', () => {
|
||||
|
||||
await expect(async () => {
|
||||
const retrievedTemplate = await getTemplateById({
|
||||
id: template.id,
|
||||
id: {
|
||||
type: 'envelopeId',
|
||||
id: template.id,
|
||||
},
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
const retrievedRecipients = await getRecipientsForTemplate({
|
||||
templateId: template.id,
|
||||
templateId: mapSecondaryIdToTemplateId(template.secondaryId),
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
@@ -172,3 +178,36 @@ test.describe('AutoSave Signers Step - Templates', () => {
|
||||
}).toPass();
|
||||
});
|
||||
});
|
||||
|
||||
export interface GetRecipientsForTemplateOptions {
|
||||
templateId: number;
|
||||
userId: number;
|
||||
teamId: number;
|
||||
}
|
||||
|
||||
const getRecipientsForTemplate = async ({
|
||||
templateId,
|
||||
userId,
|
||||
teamId,
|
||||
}: GetRecipientsForTemplateOptions) => {
|
||||
const { envelopeWhereInput } = await getEnvelopeWhereInput({
|
||||
id: {
|
||||
type: 'templateId',
|
||||
id: templateId,
|
||||
},
|
||||
type: EnvelopeType.TEMPLATE,
|
||||
userId,
|
||||
teamId,
|
||||
});
|
||||
|
||||
const recipients = await prisma.recipient.findMany({
|
||||
where: {
|
||||
envelope: envelopeWhereInput,
|
||||
},
|
||||
orderBy: {
|
||||
id: 'asc',
|
||||
},
|
||||
});
|
||||
|
||||
return recipients;
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { TeamMemberRole } from '@prisma/client';
|
||||
|
||||
import { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope';
|
||||
import { seedTeam, seedTeamMember } from '@documenso/prisma/seed/teams';
|
||||
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
@@ -118,8 +119,10 @@ test('[TEMPLATE_FLOW] team member visibility permissions', async ({ page }) => {
|
||||
redirectPath: `/t/${team.url}/templates/${template.id}/edit`,
|
||||
});
|
||||
|
||||
// Regular member should not be able to modify visibility when set to managers and above
|
||||
await expect(page.getByTestId('documentVisibilitySelectValue')).toBeDisabled();
|
||||
// A regular member should not be able to see the template.
|
||||
// They should be redirected to the templates page.
|
||||
await expect(page.getByText('Not Found').first()).toBeVisible();
|
||||
await page.goto(`/t/${team.url}/templates`);
|
||||
|
||||
// Create a new template with 'everyone' visibility
|
||||
const everyoneTemplate = await seedBlankTemplate(owner, team.id, {
|
||||
@@ -130,7 +133,9 @@ test('[TEMPLATE_FLOW] team member visibility permissions', async ({ page }) => {
|
||||
});
|
||||
|
||||
// Navigate to the new template
|
||||
await page.goto(`/t/${team.url}/templates/${everyoneTemplate.id}/edit`);
|
||||
await page.goto(
|
||||
`/t/${team.url}/templates/${mapSecondaryIdToTemplateId(everyoneTemplate.secondaryId)}/edit`,
|
||||
);
|
||||
|
||||
// Regular member should be able to see but not modify visibility
|
||||
await expect(page.getByTestId('documentVisibilitySelectValue')).toBeDisabled();
|
||||
|
||||
@@ -29,7 +29,7 @@ import { apiSignin } from '../fixtures/authentication';
|
||||
// await apiSignin({
|
||||
// page,
|
||||
// email: user.email,
|
||||
// redirectPath: `/templates/${template.id}/edit`,
|
||||
// redirectPath: `/templates/${mapSecondaryIdToTemplateId(template.secondaryId)}/edit`,
|
||||
// });
|
||||
|
||||
// // Save the settings by going to the next step.
|
||||
|
||||
@@ -75,11 +75,11 @@ test('[TEMPLATE]: should create a document from a template', async ({ page }) =>
|
||||
await page.getByRole('button', { name: 'Create as draft' }).click();
|
||||
|
||||
// Review that the document was created with the correct values.
|
||||
await page.waitForURL(new RegExp(`/t/${team.url}/documents/\\d+`));
|
||||
await page.waitForURL(new RegExp(`/t/${team.url}/documents/envelope_.*`));
|
||||
|
||||
const documentId = Number(page.url().split('/').pop());
|
||||
const documentId = page.url().split('/').pop();
|
||||
|
||||
const document = await prisma.document.findFirstOrThrow({
|
||||
const document = await prisma.envelope.findFirstOrThrow({
|
||||
where: {
|
||||
id: documentId,
|
||||
},
|
||||
@@ -178,11 +178,11 @@ test('[TEMPLATE]: should create a team document from a team template', async ({
|
||||
await page.getByRole('button', { name: 'Create as draft' }).click();
|
||||
|
||||
// Review that the document was created with the correct values.
|
||||
await page.waitForURL(new RegExp(`/t/${team.url}/documents/\\d+`));
|
||||
await page.waitForURL(new RegExp(`/t/${team.url}/documents/envelope_.*`));
|
||||
|
||||
const documentId = Number(page.url().split('/').pop());
|
||||
const documentId = page.url().split('/').pop();
|
||||
|
||||
const document = await prisma.document.findFirstOrThrow({
|
||||
const document = await prisma.envelope.findFirstOrThrow({
|
||||
where: {
|
||||
id: documentId,
|
||||
},
|
||||
@@ -284,34 +284,40 @@ test('[TEMPLATE]: should create a document from a template with custom document'
|
||||
await page.getByRole('button', { name: 'Create as draft' }).click();
|
||||
|
||||
// Review that the document was created with the custom document data
|
||||
await page.waitForURL(new RegExp(`/t/${team.url}/documents/\\d+`));
|
||||
await page.waitForURL(new RegExp(`/t/${team.url}/documents/envelope_.*`));
|
||||
|
||||
const documentId = Number(page.url().split('/').pop());
|
||||
const documentId = page.url().split('/').pop();
|
||||
|
||||
const document = await prisma.document.findFirstOrThrow({
|
||||
const document = await prisma.envelope.findFirstOrThrow({
|
||||
where: {
|
||||
id: documentId,
|
||||
},
|
||||
include: {
|
||||
documentData: true,
|
||||
envelopeItems: {
|
||||
include: {
|
||||
documentData: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const firstDocumentData = document.envelopeItems[0].documentData;
|
||||
|
||||
const expectedDocumentDataType =
|
||||
process.env.NEXT_PUBLIC_UPLOAD_TRANSPORT === 's3'
|
||||
? DocumentDataType.S3_PATH
|
||||
: DocumentDataType.BYTES_64;
|
||||
|
||||
expect(document.title).toEqual('TEMPLATE_WITH_CUSTOM_DOC');
|
||||
expect(document.documentData.type).toEqual(expectedDocumentDataType);
|
||||
expect(firstDocumentData.type).toEqual(expectedDocumentDataType);
|
||||
|
||||
if (expectedDocumentDataType === DocumentDataType.BYTES_64) {
|
||||
expect(document.documentData.data).toEqual(pdfContent);
|
||||
expect(document.documentData.initialData).toEqual(pdfContent);
|
||||
expect(firstDocumentData.data).toEqual(pdfContent);
|
||||
expect(firstDocumentData.initialData).toEqual(pdfContent);
|
||||
} else {
|
||||
// For S3, we expect the data/initialData to be the S3 path (non-empty string)
|
||||
expect(document.documentData.data).toBeTruthy();
|
||||
expect(document.documentData.initialData).toBeTruthy();
|
||||
expect(firstDocumentData.data).toBeTruthy();
|
||||
expect(firstDocumentData.initialData).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -377,16 +383,20 @@ test('[TEMPLATE]: should create a team document from a template with custom docu
|
||||
await page.getByRole('button', { name: 'Create as draft' }).click();
|
||||
|
||||
// Review that the document was created with the custom document data
|
||||
await page.waitForURL(new RegExp(`/t/${team.url}/documents/\\d+`));
|
||||
await page.waitForURL(new RegExp(`/t/${team.url}/documents/envelope_.*`));
|
||||
|
||||
const documentId = Number(page.url().split('/').pop());
|
||||
const documentId = page.url().split('/').pop();
|
||||
|
||||
const document = await prisma.document.findFirstOrThrow({
|
||||
const document = await prisma.envelope.findFirstOrThrow({
|
||||
where: {
|
||||
id: documentId,
|
||||
},
|
||||
include: {
|
||||
documentData: true,
|
||||
envelopeItems: {
|
||||
include: {
|
||||
documentData: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -395,17 +405,19 @@ test('[TEMPLATE]: should create a team document from a template with custom docu
|
||||
? DocumentDataType.S3_PATH
|
||||
: DocumentDataType.BYTES_64;
|
||||
|
||||
const firstDocumentData = document.envelopeItems[0].documentData;
|
||||
|
||||
expect(document.teamId).toEqual(team.id);
|
||||
expect(document.title).toEqual('TEAM_TEMPLATE_WITH_CUSTOM_DOC');
|
||||
expect(document.documentData.type).toEqual(expectedDocumentDataType);
|
||||
expect(firstDocumentData.type).toEqual(expectedDocumentDataType);
|
||||
|
||||
if (expectedDocumentDataType === DocumentDataType.BYTES_64) {
|
||||
expect(document.documentData.data).toEqual(pdfContent);
|
||||
expect(document.documentData.initialData).toEqual(pdfContent);
|
||||
expect(firstDocumentData.data).toEqual(pdfContent);
|
||||
expect(firstDocumentData.initialData).toEqual(pdfContent);
|
||||
} else {
|
||||
// For S3, we expect the data/initialData to be the S3 path (non-empty string)
|
||||
expect(document.documentData.data).toBeTruthy();
|
||||
expect(document.documentData.initialData).toBeTruthy();
|
||||
expect(firstDocumentData.data).toBeTruthy();
|
||||
expect(firstDocumentData.initialData).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -451,34 +463,44 @@ test('[TEMPLATE]: should create a document from a template using template docume
|
||||
await page.getByRole('button', { name: 'Create as draft' }).click();
|
||||
|
||||
// Review that the document was created with the template's document data
|
||||
await page.waitForURL(new RegExp(`/t/${team.url}/documents/\\d+`));
|
||||
await page.waitForURL(new RegExp(`/t/${team.url}/documents/envelope_.*`));
|
||||
|
||||
const documentId = Number(page.url().split('/').pop());
|
||||
const documentId = page.url().split('/').pop();
|
||||
|
||||
const document = await prisma.document.findFirstOrThrow({
|
||||
const document = await prisma.envelope.findFirstOrThrow({
|
||||
where: {
|
||||
id: documentId,
|
||||
},
|
||||
include: {
|
||||
documentData: true,
|
||||
envelopeItems: {
|
||||
include: {
|
||||
documentData: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const templateWithData = await prisma.template.findFirstOrThrow({
|
||||
const firstDocumentData = document.envelopeItems[0].documentData;
|
||||
|
||||
const templateWithData = await prisma.envelope.findFirstOrThrow({
|
||||
where: {
|
||||
id: template.id,
|
||||
},
|
||||
include: {
|
||||
templateDocumentData: true,
|
||||
envelopeItems: {
|
||||
include: {
|
||||
documentData: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(document.title).toEqual('TEMPLATE_WITH_ORIGINAL_DOC');
|
||||
expect(document.documentData.data).toEqual(templateWithData.templateDocumentData.data);
|
||||
expect(document.documentData.initialData).toEqual(
|
||||
templateWithData.templateDocumentData.initialData,
|
||||
expect(firstDocumentData.data).toEqual(templateWithData.envelopeItems[0].documentData.data);
|
||||
expect(firstDocumentData.initialData).toEqual(
|
||||
templateWithData.envelopeItems[0].documentData.initialData,
|
||||
);
|
||||
expect(document.documentData.type).toEqual(templateWithData.templateDocumentData.type);
|
||||
expect(firstDocumentData.type).toEqual(templateWithData.envelopeItems[0].documentData.type);
|
||||
});
|
||||
|
||||
test('[TEMPLATE]: should persist document visibility when creating from template', async ({
|
||||
@@ -532,14 +554,21 @@ test('[TEMPLATE]: should persist document visibility when creating from template
|
||||
await page.getByRole('button', { name: 'Create as draft' }).click();
|
||||
|
||||
// Review that the document was created with the correct visibility
|
||||
await page.waitForURL(new RegExp(`/t/${team.url}/documents/\\d+`));
|
||||
await page.waitForURL(new RegExp(`/t/${team.url}/documents/envelope_.*`));
|
||||
|
||||
const documentId = Number(page.url().split('/').pop());
|
||||
const documentId = page.url().split('/').pop();
|
||||
|
||||
const document = await prisma.document.findFirstOrThrow({
|
||||
const document = await prisma.envelope.findFirstOrThrow({
|
||||
where: {
|
||||
id: documentId,
|
||||
},
|
||||
include: {
|
||||
envelopeItems: {
|
||||
include: {
|
||||
documentData: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(document.title).toEqual('TEMPLATE_WITH_VISIBILITY');
|
||||
|
||||
@@ -77,7 +77,7 @@ test('[DIRECT_TEMPLATES]: toggle direct template link', async ({ page }) => {
|
||||
// Navigate to template settings and disable access.
|
||||
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}${formatTemplatesPath(template.team?.url)}`);
|
||||
await page.getByRole('cell', { name: 'Use Template' }).getByRole('button').nth(1).click();
|
||||
await page.getByRole('menuitem', { name: 'Direct link' }).click();
|
||||
await page.getByTestId('template-direct-link').click();
|
||||
await page.getByRole('switch').click();
|
||||
await page.getByRole('button', { name: 'Save' }).click();
|
||||
await expect(page.getByText('Direct link signing has been').first()).toBeVisible();
|
||||
@@ -111,7 +111,7 @@ test('[DIRECT_TEMPLATES]: delete direct template link', async ({ page }) => {
|
||||
// Navigate to template settings and delete the access.
|
||||
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}${formatTemplatesPath(template.team?.url)}`);
|
||||
await page.getByRole('cell', { name: 'Use Template' }).getByRole('button').nth(1).click();
|
||||
await page.getByRole('menuitem', { name: 'Direct link' }).click();
|
||||
await page.getByTestId('template-direct-link').click();
|
||||
await page.getByRole('button', { name: 'Remove' }).click();
|
||||
await page.getByRole('button', { name: 'Confirm' }).click();
|
||||
await expect(page.getByText('Direct template link deleted').first()).toBeVisible();
|
||||
@@ -171,6 +171,7 @@ test('[DIRECT_TEMPLATES]: use direct template link with 1 recipient', async ({ p
|
||||
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();
|
||||
|
||||
@@ -24,7 +24,7 @@ test.describe('Unauthorized Access to Templates', () => {
|
||||
});
|
||||
|
||||
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/t/${team.url}/templates/${template.id}`);
|
||||
await expect(page.getByRole('heading', { name: 'Oops! Something went wrong.' })).toBeVisible();
|
||||
await expect(page.getByRole('heading', { name: 'Team not found' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('should block unauthorized access to the template edit page', async ({ page }) => {
|
||||
@@ -40,6 +40,6 @@ test.describe('Unauthorized Access to Templates', () => {
|
||||
});
|
||||
|
||||
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/t/${team.url}/templates/${template.id}/edit`);
|
||||
await expect(page.getByRole('heading', { name: 'Oops! Something went wrong.' })).toBeVisible();
|
||||
await expect(page.getByRole('heading', { name: 'Team not found' })).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,13 +12,12 @@
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.52.0",
|
||||
"@types/node": "^20",
|
||||
"@documenso/lib": "*",
|
||||
"@documenso/prisma": "*",
|
||||
"pdf-lib": "^1.17.1"
|
||||
"@playwright/test": "1.52.0",
|
||||
"@types/node": "^20"
|
||||
},
|
||||
"dependencies": {
|
||||
"start-server-and-test": "^2.0.12"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { DocumentSource, SubscriptionStatus } from '@prisma/client';
|
||||
import { DocumentSource, EnvelopeType, SubscriptionStatus } from '@prisma/client';
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||
@@ -84,8 +84,9 @@ export const getServerLimits = async ({
|
||||
}
|
||||
|
||||
const [documents, directTemplates] = await Promise.all([
|
||||
prisma.document.count({
|
||||
prisma.envelope.count({
|
||||
where: {
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
team: {
|
||||
organisationId: organisation.id,
|
||||
},
|
||||
@@ -97,8 +98,9 @@ export const getServerLimits = async ({
|
||||
},
|
||||
},
|
||||
}),
|
||||
prisma.template.count({
|
||||
prisma.envelope.count({
|
||||
where: {
|
||||
type: EnvelopeType.TEMPLATE,
|
||||
team: {
|
||||
organisationId: organisation.id,
|
||||
},
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// import { numberFormatValues } from '@documenso/ui/primitives/document-flow/field-items-advanced-settings/constants';
|
||||
import { numberFormatValues } from '@documenso/ui/primitives/document-flow/field-items-advanced-settings/constants';
|
||||
|
||||
import type { TNumberFieldMeta as NumberFieldMeta } from '../types/field-meta';
|
||||
|
||||
export const validateNumberField = (
|
||||
@@ -10,16 +11,16 @@ export const validateNumberField = (
|
||||
|
||||
const { minValue, maxValue, readOnly, required, numberFormat, fontSize } = fieldMeta || {};
|
||||
|
||||
const formatRegex: { [key: string]: RegExp } = {
|
||||
'123,456,789.00': /^(?:\d{1,3}(?:,\d{3})*|\d+)(?:\.\d{1,2})?$/,
|
||||
'123.456.789,00': /^(?:\d{1,3}(?:\.\d{3})*|\d+)(?:,\d{1,2})?$/,
|
||||
'123456,789.00': /^(?:\d+)(?:,\d{1,3}(?:\.\d{1,2})?)?$/,
|
||||
};
|
||||
if (numberFormat) {
|
||||
const foundRegex = numberFormatValues.find((item) => item.value === numberFormat)?.regex;
|
||||
|
||||
const isValidFormat = numberFormat ? formatRegex[numberFormat].test(value) : true;
|
||||
if (!foundRegex) {
|
||||
errors.push(`Invalid number format - ${numberFormat}`);
|
||||
}
|
||||
|
||||
if (!isValidFormat) {
|
||||
errors.push(`Value ${value} does not match the number format - ${numberFormat}`);
|
||||
if (foundRegex && !foundRegex.test(value)) {
|
||||
errors.push(`Value ${value} does not match the number format - ${numberFormat}`);
|
||||
}
|
||||
}
|
||||
|
||||
const numberValue = parseFloat(value);
|
||||
@@ -32,19 +33,19 @@ export const validateNumberField = (
|
||||
errors.push(`Value is not a valid number`);
|
||||
}
|
||||
|
||||
if (minValue !== undefined && minValue > 0 && numberValue < minValue) {
|
||||
if (typeof minValue === 'number' && minValue > 0 && numberValue < minValue) {
|
||||
errors.push(`Value ${value} is less than the minimum value of ${minValue}`);
|
||||
}
|
||||
|
||||
if (maxValue !== undefined && maxValue > 0 && numberValue > maxValue) {
|
||||
if (typeof maxValue === 'number' && maxValue > 0 && numberValue > maxValue) {
|
||||
errors.push(`Value ${value} is greater than the maximum value of ${maxValue}`);
|
||||
}
|
||||
|
||||
if (minValue !== undefined && maxValue !== undefined && minValue > maxValue) {
|
||||
if (typeof minValue === 'number' && typeof maxValue === 'number' && minValue > maxValue) {
|
||||
errors.push('Minimum value cannot be greater than maximum value');
|
||||
}
|
||||
|
||||
if (maxValue !== undefined && minValue !== undefined && maxValue < minValue) {
|
||||
if (typeof maxValue === 'number' && typeof minValue === 'number' && maxValue < minValue) {
|
||||
errors.push('Maximum value cannot be less than minimum value');
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import type { TCreateOrGetShareLinkMutationSchema } from '@documenso/trpc/server/share-link-router/schema';
|
||||
import type { TShareDocumentRequest } from '@documenso/trpc/server/document-router/share-document.types';
|
||||
|
||||
import { useCopyToClipboard } from './use-copy-to-clipboard';
|
||||
|
||||
@@ -12,14 +12,14 @@ export function useCopyShareLink({ onSuccess, onError }: UseCopyShareLinkOptions
|
||||
const [, copyToClipboard] = useCopyToClipboard();
|
||||
|
||||
const { mutateAsync: createOrGetShareLink, isPending: isCreatingShareLink } =
|
||||
trpc.shareLink.createOrGetShareLink.useMutation();
|
||||
trpc.document.share.useMutation();
|
||||
|
||||
/**
|
||||
* Copy a newly created, or pre-existing share link to the user's clipboard.
|
||||
*
|
||||
* @param payload The payload to create or get a share link.
|
||||
*/
|
||||
const createAndCopyShareLink = async (payload: TCreateOrGetShareLinkMutationSchema) => {
|
||||
const createAndCopyShareLink = async (payload: TShareDocumentRequest) => {
|
||||
const valueToCopy = createOrGetShareLink(payload).then(
|
||||
(result) => `${window.location.origin}/share/${result.slug}`,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import type { Recipient } from '@prisma/client';
|
||||
import { FieldType } from '@prisma/client';
|
||||
import { useFieldArray, useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
|
||||
import { nanoid } from '@documenso/lib/universal/id';
|
||||
|
||||
import type { TEnvelope } from '../../types/envelope';
|
||||
|
||||
export const ZLocalFieldSchema = z.object({
|
||||
// This is the actual ID of the field if created.
|
||||
id: z.number().optional(),
|
||||
// This is the local client side ID of the field.
|
||||
formId: z.string().min(1),
|
||||
// This is the ID of the envelope item to put the field on.
|
||||
envelopeItemId: z.string(),
|
||||
type: z.nativeEnum(FieldType),
|
||||
recipientId: z.number(),
|
||||
page: z.number().min(1),
|
||||
positionX: z.number().min(0),
|
||||
positionY: z.number().min(0),
|
||||
width: z.number().min(0),
|
||||
height: z.number().min(0),
|
||||
fieldMeta: ZFieldMetaSchema,
|
||||
});
|
||||
|
||||
export type TLocalField = z.infer<typeof ZLocalFieldSchema>;
|
||||
|
||||
const ZEditorFieldsFormSchema = z.object({
|
||||
fields: z.array(ZLocalFieldSchema),
|
||||
});
|
||||
|
||||
export type TEditorFieldsFormSchema = z.infer<typeof ZEditorFieldsFormSchema>;
|
||||
|
||||
type EditorFieldsProps = {
|
||||
envelope: TEnvelope;
|
||||
handleFieldsUpdate: (fields: TLocalField[]) => unknown;
|
||||
};
|
||||
|
||||
type UseEditorFieldsResponse = {
|
||||
localFields: TLocalField[];
|
||||
|
||||
// Selected field
|
||||
selectedField: TLocalField | undefined;
|
||||
setSelectedField: (formId: string | null) => void;
|
||||
|
||||
// Field operations
|
||||
addField: (field: Omit<TLocalField, 'formId'>) => TLocalField;
|
||||
removeFieldsByFormId: (formIds: string[]) => void;
|
||||
updateFieldByFormId: (formId: string, updates: Partial<TLocalField>) => void;
|
||||
duplicateField: (field: TLocalField, recipientId?: number) => TLocalField;
|
||||
duplicateFieldToAllPages: (field: TLocalField, recipientId?: number) => TLocalField[];
|
||||
|
||||
// Field utilities
|
||||
getFieldByFormId: (formId: string) => TLocalField | undefined;
|
||||
getFieldsByRecipient: (recipientId: number) => TLocalField[];
|
||||
|
||||
// Selected recipient
|
||||
selectedRecipient: Recipient | null;
|
||||
setSelectedRecipient: (recipientId: number | null) => void;
|
||||
};
|
||||
|
||||
export const useEditorFields = ({
|
||||
envelope,
|
||||
handleFieldsUpdate,
|
||||
}: EditorFieldsProps): UseEditorFieldsResponse => {
|
||||
const [selectedFieldFormId, setSelectedFieldFormId] = useState<string | null>(null);
|
||||
const [selectedRecipientId, setSelectedRecipientId] = useState<number | null>(null);
|
||||
|
||||
const form = useForm<TEditorFieldsFormSchema>({
|
||||
defaultValues: {
|
||||
fields: envelope.fields.map(
|
||||
(field): TLocalField => ({
|
||||
id: field.id,
|
||||
formId: nanoid(),
|
||||
envelopeItemId: field.envelopeItemId,
|
||||
page: field.page,
|
||||
type: field.type,
|
||||
positionX: Number(field.positionX),
|
||||
positionY: Number(field.positionY),
|
||||
width: Number(field.width),
|
||||
height: Number(field.height),
|
||||
recipientId: field.recipientId,
|
||||
fieldMeta: field.fieldMeta ? ZFieldMetaSchema.parse(field.fieldMeta) : undefined,
|
||||
}),
|
||||
),
|
||||
},
|
||||
resolver: zodResolver(ZEditorFieldsFormSchema),
|
||||
});
|
||||
|
||||
const {
|
||||
append,
|
||||
remove,
|
||||
update,
|
||||
fields: localFields,
|
||||
} = useFieldArray({
|
||||
control: form.control,
|
||||
name: 'fields',
|
||||
keyName: 'react-hook-form-id',
|
||||
});
|
||||
|
||||
const triggerFieldsUpdate = () => {
|
||||
void handleFieldsUpdate(form.getValues().fields);
|
||||
};
|
||||
|
||||
const setSelectedField = (formId: string | null, bypassCheck = false) => {
|
||||
if (!formId) {
|
||||
setSelectedFieldFormId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const foundField = localFields.find((field) => field.formId === formId);
|
||||
const recipient = envelope.recipients.find(
|
||||
(recipient) => recipient.id === foundField?.recipientId,
|
||||
);
|
||||
|
||||
if (recipient) {
|
||||
setSelectedRecipient(recipient.id);
|
||||
}
|
||||
|
||||
if (bypassCheck) {
|
||||
console.log(3);
|
||||
setSelectedFieldFormId(formId);
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedFieldFormId(foundField?.formId ?? null);
|
||||
};
|
||||
|
||||
const addField = useCallback(
|
||||
(fieldData: Omit<TLocalField, 'formId'>): TLocalField => {
|
||||
const field: TLocalField = {
|
||||
...fieldData,
|
||||
formId: nanoid(12),
|
||||
};
|
||||
|
||||
append(field);
|
||||
triggerFieldsUpdate();
|
||||
setSelectedField(field.formId, true);
|
||||
return field;
|
||||
},
|
||||
[append, triggerFieldsUpdate, setSelectedField],
|
||||
);
|
||||
|
||||
const removeFieldsByFormId = useCallback(
|
||||
(formIds: string[]) => {
|
||||
const indexes = formIds
|
||||
.map((formId) => localFields.findIndex((field) => field.formId === formId))
|
||||
.filter((index) => index !== -1);
|
||||
|
||||
if (indexes.length > 0) {
|
||||
remove(indexes);
|
||||
triggerFieldsUpdate();
|
||||
}
|
||||
},
|
||||
[localFields, remove, triggerFieldsUpdate],
|
||||
);
|
||||
|
||||
const updateFieldByFormId = useCallback(
|
||||
(formId: string, updates: Partial<TLocalField>) => {
|
||||
const index = localFields.findIndex((field) => field.formId === formId);
|
||||
|
||||
if (index !== -1) {
|
||||
update(index, { ...localFields[index], ...updates });
|
||||
triggerFieldsUpdate();
|
||||
}
|
||||
},
|
||||
[localFields, update, triggerFieldsUpdate],
|
||||
);
|
||||
|
||||
const duplicateField = useCallback(
|
||||
(field: TLocalField): TLocalField => {
|
||||
const newField: TLocalField = {
|
||||
...structuredClone(field),
|
||||
id: undefined,
|
||||
formId: nanoid(12),
|
||||
recipientId: field.recipientId,
|
||||
positionX: field.positionX + 3,
|
||||
positionY: field.positionY + 3,
|
||||
};
|
||||
|
||||
append(newField);
|
||||
triggerFieldsUpdate();
|
||||
return newField;
|
||||
},
|
||||
[append, triggerFieldsUpdate],
|
||||
);
|
||||
|
||||
const duplicateFieldToAllPages = useCallback(
|
||||
(field: TLocalField): TLocalField[] => {
|
||||
const pages = Array.from(document.querySelectorAll('[data-page-number]'));
|
||||
const newFields: TLocalField[] = [];
|
||||
|
||||
pages.forEach((_, index) => {
|
||||
const pageNumber = index + 1;
|
||||
|
||||
if (pageNumber === field.page) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newField: TLocalField = {
|
||||
...structuredClone(field),
|
||||
id: undefined,
|
||||
formId: nanoid(12),
|
||||
page: pageNumber,
|
||||
};
|
||||
|
||||
append(newField);
|
||||
newFields.push(newField);
|
||||
});
|
||||
|
||||
triggerFieldsUpdate();
|
||||
return newFields;
|
||||
},
|
||||
[append, triggerFieldsUpdate],
|
||||
);
|
||||
|
||||
const getFieldByFormId = useCallback(
|
||||
(formId: string): TLocalField | undefined => {
|
||||
return localFields.find((field) => field.formId === formId) as TLocalField | undefined;
|
||||
},
|
||||
[localFields],
|
||||
);
|
||||
|
||||
const getFieldsByRecipient = useCallback(
|
||||
(recipientId: number): TLocalField[] => {
|
||||
return localFields.filter((field) => field.recipientId === recipientId);
|
||||
},
|
||||
[localFields],
|
||||
);
|
||||
|
||||
const selectedRecipient = useMemo(() => {
|
||||
return envelope.recipients.find((recipient) => recipient.id === selectedRecipientId) || null;
|
||||
}, [selectedRecipientId, envelope.recipients]);
|
||||
|
||||
const selectedField = useMemo(() => {
|
||||
return localFields.find((field) => field.formId === selectedFieldFormId);
|
||||
}, [selectedFieldFormId, localFields]);
|
||||
|
||||
/**
|
||||
* Keep the selected field form ID in sync with the local fields.
|
||||
*/
|
||||
useEffect(() => {
|
||||
const foundField = localFields.find((field) => field.formId === selectedFieldFormId);
|
||||
setSelectedFieldFormId(foundField?.formId ?? null);
|
||||
}, [selectedFieldFormId, localFields]);
|
||||
|
||||
const setSelectedRecipient = (recipientId: number | null) => {
|
||||
const foundRecipient = envelope.recipients.find((recipient) => recipient.id === recipientId);
|
||||
|
||||
setSelectedRecipientId(foundRecipient?.id ?? null);
|
||||
};
|
||||
|
||||
return {
|
||||
// Core state
|
||||
localFields,
|
||||
|
||||
// Field operations
|
||||
addField,
|
||||
removeFieldsByFormId,
|
||||
updateFieldByFormId,
|
||||
duplicateField,
|
||||
duplicateFieldToAllPages,
|
||||
|
||||
// Field utilities
|
||||
getFieldByFormId,
|
||||
getFieldsByRecipient,
|
||||
|
||||
// Selected field
|
||||
selectedField,
|
||||
setSelectedField,
|
||||
|
||||
// Selected recipient
|
||||
selectedRecipient,
|
||||
setSelectedRecipient,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
export function useEnvelopeAutosave<T>(saveFn: (data: T) => Promise<void>, delay = 1000) {
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const lastArgsRef = useRef<T | null>(null);
|
||||
const pendingPromiseRef = useRef<Promise<void> | null>(null);
|
||||
|
||||
const [isPending, setIsPending] = useState(false);
|
||||
const [isCommiting, setIsCommiting] = useState(false);
|
||||
|
||||
const triggerSave = useCallback(
|
||||
(data: T) => {
|
||||
lastArgsRef.current = data;
|
||||
|
||||
// A debounce or promise means something is pending
|
||||
setIsPending(true);
|
||||
|
||||
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises
|
||||
timeoutRef.current = setTimeout(async () => {
|
||||
if (!lastArgsRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const args = lastArgsRef.current;
|
||||
lastArgsRef.current = null;
|
||||
timeoutRef.current = null;
|
||||
|
||||
setIsCommiting(true);
|
||||
pendingPromiseRef.current = saveFn(args);
|
||||
|
||||
try {
|
||||
await pendingPromiseRef.current;
|
||||
} finally {
|
||||
// eslint-disable-next-line require-atomic-updates
|
||||
pendingPromiseRef.current = null;
|
||||
setIsCommiting(false);
|
||||
setIsPending(false);
|
||||
}
|
||||
}, delay);
|
||||
},
|
||||
[saveFn, delay],
|
||||
);
|
||||
|
||||
const flush = useCallback(async () => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
}
|
||||
|
||||
if (pendingPromiseRef.current) {
|
||||
// Already running → wait for it
|
||||
await pendingPromiseRef.current;
|
||||
return;
|
||||
}
|
||||
|
||||
if (lastArgsRef.current) {
|
||||
const args = lastArgsRef.current;
|
||||
lastArgsRef.current = null;
|
||||
|
||||
setIsCommiting(true);
|
||||
setIsPending(true);
|
||||
|
||||
pendingPromiseRef.current = saveFn(args);
|
||||
try {
|
||||
await pendingPromiseRef.current;
|
||||
} finally {
|
||||
// eslint-disable-next-line require-atomic-updates
|
||||
pendingPromiseRef.current = null;
|
||||
setIsCommiting(false);
|
||||
setIsPending(false);
|
||||
}
|
||||
}
|
||||
}, [saveFn]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleBeforeUnload = () => {
|
||||
if (timeoutRef.current || pendingPromiseRef.current) {
|
||||
void flush();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('beforeunload', handleBeforeUnload);
|
||||
|
||||
return () => window.removeEventListener('beforeunload', handleBeforeUnload);
|
||||
}, [flush]);
|
||||
|
||||
return { triggerSave, flush, isPending, isCommiting };
|
||||
}
|
||||
@@ -5,7 +5,9 @@ import type { Field } from '@prisma/client';
|
||||
import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect';
|
||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||
|
||||
export const useFieldPageCoords = (field: Field) => {
|
||||
export const useFieldPageCoords = (
|
||||
field: Pick<Field, 'positionX' | 'positionY' | 'width' | 'height' | 'page'>,
|
||||
) => {
|
||||
const [coords, setCoords] = useState({
|
||||
x: 0,
|
||||
y: 0,
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
import React, { createContext, useCallback, useContext, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { EnvelopeType } from '@prisma/client';
|
||||
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import type { TSetEnvelopeRecipientsRequest } from '@documenso/trpc/server/envelope-router/set-envelope-recipients.types';
|
||||
import type { RecipientColorStyles, TRecipientColor } from '@documenso/ui/lib/recipient-colors';
|
||||
import {
|
||||
AVAILABLE_RECIPIENT_COLORS,
|
||||
getRecipientColorStyles,
|
||||
} from '@documenso/ui/lib/recipient-colors';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import type { TDocumentEmailSettings } from '../../types/document-email';
|
||||
import type { TEnvelope } from '../../types/envelope';
|
||||
import { useEditorFields } from '../hooks/use-editor-fields';
|
||||
import type { TLocalField } from '../hooks/use-editor-fields';
|
||||
import { useEnvelopeAutosave } from '../hooks/use-envelope-autosave';
|
||||
|
||||
export const useDebounceFunction = <Args extends unknown[]>(
|
||||
callback: (...args: Args) => void,
|
||||
delay: number,
|
||||
) => {
|
||||
const timeoutRef = useRef<NodeJS.Timeout>();
|
||||
|
||||
return useCallback(
|
||||
(...args: Args) => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
callback(...args);
|
||||
}, delay);
|
||||
},
|
||||
[callback, delay],
|
||||
);
|
||||
};
|
||||
|
||||
type EnvelopeEditorProviderValue = {
|
||||
envelope: TEnvelope;
|
||||
isDocument: boolean;
|
||||
isTemplate: boolean;
|
||||
setLocalEnvelope: (localEnvelope: Partial<TEnvelope>) => void;
|
||||
|
||||
updateEnvelope: (envelopeUpdates: Partial<TEnvelope>) => void;
|
||||
setRecipientsDebounced: (recipients: TSetEnvelopeRecipientsRequest['recipients']) => void;
|
||||
setRecipientsAsync: (recipients: TSetEnvelopeRecipientsRequest['recipients']) => Promise<void>;
|
||||
|
||||
getFieldColor: (field: TLocalField) => RecipientColorStyles;
|
||||
getRecipientColorKey: (recipientId: number) => TRecipientColor;
|
||||
|
||||
editorFields: ReturnType<typeof useEditorFields>;
|
||||
|
||||
isAutosaving: boolean;
|
||||
flushAutosave: () => void;
|
||||
autosaveError: boolean;
|
||||
|
||||
// refetchEnvelope: () => Promise<void>;
|
||||
// updateEnvelope: (envelope: TEnvelope) => Promise<void>;
|
||||
};
|
||||
|
||||
interface EnvelopeEditorProviderProps {
|
||||
children: React.ReactNode;
|
||||
initialEnvelope: TEnvelope;
|
||||
}
|
||||
|
||||
const EnvelopeEditorContext = createContext<EnvelopeEditorProviderValue | null>(null);
|
||||
|
||||
export const useCurrentEnvelopeEditor = () => {
|
||||
const context = useContext(EnvelopeEditorContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error('useCurrentEnvelopeEditor must be used within a EnvelopeEditorProvider');
|
||||
}
|
||||
|
||||
return context;
|
||||
};
|
||||
|
||||
export const EnvelopeEditorProvider = ({
|
||||
children,
|
||||
initialEnvelope,
|
||||
}: EnvelopeEditorProviderProps) => {
|
||||
const { t } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const [envelope, setEnvelope] = useState(initialEnvelope);
|
||||
|
||||
const [autosaveError, setAutosaveError] = useState<boolean>(false);
|
||||
|
||||
const envelopeUpdateMutationQuery = trpc.envelope.update.useMutation({
|
||||
onSuccess: (response, input) => {
|
||||
console.log(input.meta?.emailSettings);
|
||||
setEnvelope({
|
||||
...envelope,
|
||||
...response,
|
||||
documentMeta: {
|
||||
...envelope.documentMeta,
|
||||
...input.meta,
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
emailSettings: (input.meta?.emailSettings ||
|
||||
null) as unknown as TDocumentEmailSettings | null,
|
||||
},
|
||||
});
|
||||
|
||||
setAutosaveError(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
setAutosaveError(true);
|
||||
|
||||
toast({
|
||||
title: t`Save failed`,
|
||||
description: t`We encountered an error while attempting to save your changes. Your changes cannot be saved at this time.`,
|
||||
variant: 'destructive',
|
||||
duration: 7500,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const envelopeFieldSetMutationQuery = trpc.envelope.field.set.useMutation({
|
||||
onSuccess: () => {
|
||||
setAutosaveError(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
setAutosaveError(true);
|
||||
|
||||
toast({
|
||||
title: t`Save failed`,
|
||||
description: t`We encountered an error while attempting to save your changes. Your changes cannot be saved at this time.`,
|
||||
variant: 'destructive',
|
||||
duration: 7500,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const envelopeRecipientSetMutationQuery = trpc.envelope.recipient.set.useMutation({
|
||||
onSuccess: () => {
|
||||
setAutosaveError(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
setAutosaveError(true);
|
||||
|
||||
toast({
|
||||
title: t`Save failed`,
|
||||
description: t`We encountered an error while attempting to save your changes. Your changes cannot be saved at this time.`,
|
||||
variant: 'destructive',
|
||||
duration: 7500,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
triggerSave: setRecipientsDebounced,
|
||||
flush: setRecipientsAsync,
|
||||
isPending: isRecipientsMutationPending,
|
||||
} = useEnvelopeAutosave(async (recipients: TSetEnvelopeRecipientsRequest['recipients']) => {
|
||||
await envelopeRecipientSetMutationQuery.mutateAsync({
|
||||
envelopeId: envelope.id,
|
||||
envelopeType: envelope.type,
|
||||
recipients,
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
const {
|
||||
triggerSave: setFieldsDebounced,
|
||||
flush: setFieldsAsync,
|
||||
isPending: isFieldsMutationPending,
|
||||
} = useEnvelopeAutosave(async (fields: TLocalField[]) => {
|
||||
await envelopeFieldSetMutationQuery.mutateAsync({
|
||||
envelopeId: envelope.id,
|
||||
envelopeType: envelope.type,
|
||||
fields,
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
const {
|
||||
triggerSave: setEnvelopeDebounced,
|
||||
flush: setEnvelopeAsync,
|
||||
isPending: isEnvelopeMutationPending,
|
||||
} = useEnvelopeAutosave(async (envelopeUpdates: Partial<TEnvelope>) => {
|
||||
await envelopeUpdateMutationQuery.mutateAsync({
|
||||
envelopeId: envelope.id,
|
||||
envelopeType: envelope.type,
|
||||
data: {
|
||||
...envelopeUpdates,
|
||||
},
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
/**
|
||||
* Updates the local envelope and debounces the update to the server.
|
||||
*/
|
||||
const updateEnvelope = (envelopeUpdates: Partial<TEnvelope>) => {
|
||||
setEnvelope((prev) => ({ ...prev, ...envelopeUpdates }));
|
||||
setEnvelopeDebounced(envelopeUpdates);
|
||||
};
|
||||
|
||||
const editorFields = useEditorFields({
|
||||
envelope,
|
||||
handleFieldsUpdate: (fields) => setFieldsDebounced(fields),
|
||||
});
|
||||
|
||||
const getFieldColor = useCallback(
|
||||
(field: TLocalField) => {
|
||||
// Todo: Envelopes - Local recipients
|
||||
const recipientIndex = envelope.recipients.findIndex(
|
||||
(recipient) => recipient.id === field.recipientId,
|
||||
);
|
||||
|
||||
return getRecipientColorStyles(Math.max(recipientIndex, 0));
|
||||
},
|
||||
[envelope.recipients], // Todo: Envelopes - Local recipients
|
||||
);
|
||||
|
||||
const getRecipientColorKey = useCallback(
|
||||
(recipientId: number) => {
|
||||
// Todo: Envelopes - Local recipients
|
||||
const recipientIndex = envelope.recipients.findIndex(
|
||||
(recipient) => recipient.id === recipientId,
|
||||
);
|
||||
|
||||
return AVAILABLE_RECIPIENT_COLORS[Math.max(recipientIndex, 0)];
|
||||
},
|
||||
[envelope.recipients], // Todo: Envelopes - Local recipients
|
||||
);
|
||||
|
||||
const { refetch: reloadEnvelope, isLoading: isReloadingEnvelope } = trpc.envelope.get.useQuery(
|
||||
{
|
||||
envelopeId: envelope.id,
|
||||
},
|
||||
{
|
||||
initialData: envelope,
|
||||
},
|
||||
);
|
||||
|
||||
const setLocalEnvelope = (localEnvelope: Partial<TEnvelope>) => {
|
||||
setEnvelope((prev) => ({ ...prev, ...localEnvelope }));
|
||||
};
|
||||
|
||||
const isAutosaving = useMemo(() => {
|
||||
return (
|
||||
envelopeFieldSetMutationQuery.isPending ||
|
||||
envelopeRecipientSetMutationQuery.isPending ||
|
||||
envelopeUpdateMutationQuery.isPending ||
|
||||
isFieldsMutationPending ||
|
||||
isRecipientsMutationPending ||
|
||||
isEnvelopeMutationPending
|
||||
);
|
||||
}, [
|
||||
envelopeFieldSetMutationQuery.isPending,
|
||||
envelopeRecipientSetMutationQuery.isPending,
|
||||
envelopeUpdateMutationQuery.isPending,
|
||||
isFieldsMutationPending,
|
||||
isRecipientsMutationPending,
|
||||
isEnvelopeMutationPending,
|
||||
]);
|
||||
|
||||
const flushAutosave = () => {
|
||||
void setFieldsAsync();
|
||||
void setRecipientsAsync();
|
||||
void setEnvelopeAsync();
|
||||
};
|
||||
|
||||
return (
|
||||
<EnvelopeEditorContext.Provider
|
||||
value={{
|
||||
envelope,
|
||||
isDocument: envelope.type === EnvelopeType.DOCUMENT,
|
||||
isTemplate: envelope.type === EnvelopeType.TEMPLATE,
|
||||
setLocalEnvelope,
|
||||
getFieldColor,
|
||||
getRecipientColorKey,
|
||||
updateEnvelope,
|
||||
setRecipientsDebounced,
|
||||
setRecipientsAsync,
|
||||
editorFields,
|
||||
autosaveError,
|
||||
flushAutosave,
|
||||
isAutosaving,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</EnvelopeEditorContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,148 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
||||
import React from 'react';
|
||||
|
||||
import type { DocumentData } from '@prisma/client';
|
||||
|
||||
import type { TEnvelope } from '../../types/envelope';
|
||||
import { getFile } from '../../universal/upload/get-file';
|
||||
|
||||
type FileData =
|
||||
| {
|
||||
status: 'loading' | 'error';
|
||||
}
|
||||
| {
|
||||
file: Uint8Array;
|
||||
status: 'loaded';
|
||||
};
|
||||
|
||||
type EnvelopeRenderItem = TEnvelope['envelopeItems'][number];
|
||||
|
||||
type EnvelopeRenderProviderValue = {
|
||||
getPdfBuffer: (documentDataId: string) => FileData | null;
|
||||
envelopeItems: EnvelopeRenderItem[];
|
||||
currentEnvelopeItem: EnvelopeRenderItem | null;
|
||||
setCurrentEnvelopeItem: (envelopeItemId: string) => void;
|
||||
fields: TEnvelope['fields'];
|
||||
};
|
||||
|
||||
interface EnvelopeRenderProviderProps {
|
||||
children: React.ReactNode;
|
||||
envelope: Pick<TEnvelope, 'envelopeItems'>;
|
||||
|
||||
/**
|
||||
* Optional fields which are passed down to renderers for custom rendering needs.
|
||||
*
|
||||
* Only pass if the CustomRenderer you are passing in wants fields.
|
||||
*/
|
||||
fields?: TEnvelope['fields'];
|
||||
}
|
||||
|
||||
const EnvelopeRenderContext = createContext<EnvelopeRenderProviderValue | null>(null);
|
||||
|
||||
export const useCurrentEnvelopeRender = () => {
|
||||
const context = useContext(EnvelopeRenderContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error('useCurrentEnvelopeRender must be used within a EnvelopeRenderProvider');
|
||||
}
|
||||
|
||||
return context;
|
||||
};
|
||||
|
||||
/**
|
||||
* Manages fetching and storing PDF files to render on the client.
|
||||
*/
|
||||
export const EnvelopeRenderProvider = ({
|
||||
children,
|
||||
envelope,
|
||||
fields,
|
||||
}: EnvelopeRenderProviderProps) => {
|
||||
// Indexed by documentDataId.
|
||||
const [files, setFiles] = useState<Record<string, FileData>>({});
|
||||
|
||||
const [currentItem, setItem] = useState<EnvelopeRenderItem | null>(null);
|
||||
|
||||
const envelopeItems = useMemo(
|
||||
() => envelope.envelopeItems.sort((a, b) => a.order - b.order),
|
||||
[envelope.envelopeItems],
|
||||
);
|
||||
|
||||
const loadEnvelopeItemPdfFile = async (documentData: DocumentData) => {
|
||||
if (files[documentData.id]?.status === 'loading') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!files[documentData.id]) {
|
||||
setFiles((prev) => ({
|
||||
...prev,
|
||||
[documentData.id]: {
|
||||
status: 'loading',
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
try {
|
||||
const file = await getFile(documentData);
|
||||
|
||||
setFiles((prev) => ({
|
||||
...prev,
|
||||
[documentData.id]: {
|
||||
file,
|
||||
status: 'loaded',
|
||||
},
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
|
||||
setFiles((prev) => ({
|
||||
...prev,
|
||||
[documentData.id]: {
|
||||
status: 'error',
|
||||
},
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const getPdfBuffer = useCallback(
|
||||
(documentDataId: string) => {
|
||||
return files[documentDataId] || null;
|
||||
},
|
||||
[files],
|
||||
);
|
||||
|
||||
const setCurrentEnvelopeItem = (envelopeItemId: string) => {
|
||||
const foundItem = envelope.envelopeItems.find((item) => item.id === envelopeItemId);
|
||||
|
||||
setItem(foundItem ?? null);
|
||||
};
|
||||
|
||||
// Set the selected item to the first item if none is set.
|
||||
useEffect(() => {
|
||||
if (!currentItem && envelopeItems.length > 0) {
|
||||
setCurrentEnvelopeItem(envelopeItems[0].id);
|
||||
}
|
||||
}, [currentItem, envelopeItems]);
|
||||
|
||||
// Look for any missing pdf files and load them.
|
||||
useEffect(() => {
|
||||
const missingFiles = envelope.envelopeItems.filter((item) => !files[item.documentDataId]);
|
||||
|
||||
for (const item of missingFiles) {
|
||||
void loadEnvelopeItemPdfFile(item.documentData);
|
||||
}
|
||||
}, [envelope.envelopeItems]);
|
||||
|
||||
return (
|
||||
<EnvelopeRenderContext.Provider
|
||||
value={{
|
||||
getPdfBuffer,
|
||||
envelopeItems,
|
||||
currentEnvelopeItem: currentItem,
|
||||
setCurrentEnvelopeItem,
|
||||
fields: fields ?? [],
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</EnvelopeRenderContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import { OrganisationGroupType, TeamMemberRole } from '@prisma/client';
|
||||
import { DocumentVisibility, OrganisationGroupType, TeamMemberRole } from '@prisma/client';
|
||||
|
||||
export const TEAM_URL_ROOT_REGEX = new RegExp('^/t/[^/]+/?$');
|
||||
export const TEAM_URL_REGEX = new RegExp('^/t/[^/]+');
|
||||
@@ -33,6 +33,16 @@ export const TEAM_MEMBER_ROLE_PERMISSIONS_MAP = {
|
||||
MANAGE_TEAM: [TeamMemberRole.ADMIN, TeamMemberRole.MANAGER],
|
||||
} satisfies Record<string, TeamMemberRole[]>;
|
||||
|
||||
export const TEAM_DOCUMENT_VISIBILITY_MAP = {
|
||||
[TeamMemberRole.ADMIN]: [
|
||||
DocumentVisibility.ADMIN,
|
||||
DocumentVisibility.MANAGER_AND_ABOVE,
|
||||
DocumentVisibility.EVERYONE,
|
||||
],
|
||||
[TeamMemberRole.MANAGER]: [DocumentVisibility.MANAGER_AND_ABOVE, DocumentVisibility.EVERYONE],
|
||||
[TeamMemberRole.MEMBER]: [DocumentVisibility.EVERYONE],
|
||||
} satisfies Record<TeamMemberRole, DocumentVisibility[]>;
|
||||
|
||||
/**
|
||||
* A hierarchy of team member roles to determine which role has higher permission than another.
|
||||
*
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createElement } from 'react';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { ReadStatus, SendStatus, SigningStatus } from '@prisma/client';
|
||||
import { EnvelopeType, ReadStatus, SendStatus, SigningStatus } from '@prisma/client';
|
||||
|
||||
import { mailer } from '@documenso/email/mailer';
|
||||
import DocumentCancelTemplate from '@documenso/email/templates/document-cancel';
|
||||
@@ -11,6 +11,7 @@ import { getI18nInstance } from '../../../client-only/providers/i18n-server';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
|
||||
import { getEmailContext } from '../../../server-only/email/get-email-context';
|
||||
import { extractDerivedDocumentEmailSettings } from '../../../types/document-email';
|
||||
import { unsafeBuildEnvelopeIdQuery } from '../../../utils/envelope';
|
||||
import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
|
||||
import type { JobRunIO } from '../../client/_internal/job';
|
||||
import type { TSendDocumentCancelledEmailsJobDefinition } from './send-document-cancelled-emails';
|
||||
@@ -24,10 +25,14 @@ export const run = async ({
|
||||
}) => {
|
||||
const { documentId, cancellationReason } = payload;
|
||||
|
||||
const document = await prisma.document.findFirstOrThrow({
|
||||
where: {
|
||||
id: documentId,
|
||||
},
|
||||
const envelope = await prisma.envelope.findFirstOrThrow({
|
||||
where: unsafeBuildEnvelopeIdQuery(
|
||||
{
|
||||
type: 'documentId',
|
||||
id: documentId,
|
||||
},
|
||||
EnvelopeType.DOCUMENT,
|
||||
),
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
@@ -52,12 +57,12 @@ export const run = async ({
|
||||
emailType: 'RECIPIENT',
|
||||
source: {
|
||||
type: 'team',
|
||||
teamId: document.teamId,
|
||||
teamId: envelope.teamId,
|
||||
},
|
||||
meta: document.documentMeta,
|
||||
meta: envelope.documentMeta,
|
||||
});
|
||||
|
||||
const { documentMeta, user: documentOwner } = document;
|
||||
const { documentMeta, user: documentOwner } = envelope;
|
||||
|
||||
// Check if document cancellation emails are enabled
|
||||
const isEmailEnabled = extractDerivedDocumentEmailSettings(documentMeta).documentDeleted;
|
||||
@@ -69,7 +74,7 @@ export const run = async ({
|
||||
const i18n = await getI18nInstance(emailLanguage);
|
||||
|
||||
// Send cancellation emails to all recipients who have been sent the document or viewed it
|
||||
const recipientsToNotify = document.recipients.filter(
|
||||
const recipientsToNotify = envelope.recipients.filter(
|
||||
(recipient) =>
|
||||
(recipient.sendStatus === SendStatus.SENT || recipient.readStatus === ReadStatus.OPENED) &&
|
||||
recipient.signingStatus !== SigningStatus.REJECTED,
|
||||
@@ -79,7 +84,7 @@ export const run = async ({
|
||||
await Promise.all(
|
||||
recipientsToNotify.map(async (recipient) => {
|
||||
const template = createElement(DocumentCancelTemplate, {
|
||||
documentName: document.title,
|
||||
documentName: envelope.title,
|
||||
inviterName: documentOwner.name || undefined,
|
||||
inviterEmail: documentOwner.email,
|
||||
assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL(),
|
||||
@@ -102,7 +107,7 @@ export const run = async ({
|
||||
},
|
||||
from: senderEmail,
|
||||
replyTo: replyToEmail,
|
||||
subject: i18n._(msg`Document "${document.title}" Cancelled`),
|
||||
subject: i18n._(msg`Document "${envelope.title}" Cancelled`),
|
||||
html,
|
||||
text,
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createElement } from 'react';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { EnvelopeType } from '@prisma/client';
|
||||
|
||||
import { mailer } from '@documenso/email/mailer';
|
||||
import { DocumentRecipientSignedEmailTemplate } from '@documenso/email/templates/document-recipient-signed';
|
||||
@@ -10,6 +11,7 @@ import { getI18nInstance } from '../../../client-only/providers/i18n-server';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
|
||||
import { getEmailContext } from '../../../server-only/email/get-email-context';
|
||||
import { extractDerivedDocumentEmailSettings } from '../../../types/document-email';
|
||||
import { unsafeBuildEnvelopeIdQuery } from '../../../utils/envelope';
|
||||
import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
|
||||
import type { JobRunIO } from '../../client/_internal/job';
|
||||
import type { TSendRecipientSignedEmailJobDefinition } from './send-recipient-signed-email';
|
||||
@@ -23,9 +25,15 @@ export const run = async ({
|
||||
}) => {
|
||||
const { documentId, recipientId } = payload;
|
||||
|
||||
const document = await prisma.document.findFirst({
|
||||
const envelope = await prisma.envelope.findFirst({
|
||||
where: {
|
||||
id: documentId,
|
||||
...unsafeBuildEnvelopeIdQuery(
|
||||
{
|
||||
type: 'documentId',
|
||||
id: documentId,
|
||||
},
|
||||
EnvelopeType.DOCUMENT,
|
||||
),
|
||||
recipients: {
|
||||
some: {
|
||||
id: recipientId,
|
||||
@@ -49,25 +57,25 @@ export const run = async ({
|
||||
},
|
||||
});
|
||||
|
||||
if (!document) {
|
||||
if (!envelope) {
|
||||
throw new Error('Document not found');
|
||||
}
|
||||
|
||||
if (document.recipients.length === 0) {
|
||||
if (envelope.recipients.length === 0) {
|
||||
throw new Error('Document has no recipients');
|
||||
}
|
||||
|
||||
const isRecipientSignedEmailEnabled = extractDerivedDocumentEmailSettings(
|
||||
document.documentMeta,
|
||||
envelope.documentMeta,
|
||||
).recipientSigned;
|
||||
|
||||
if (!isRecipientSignedEmailEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [recipient] = document.recipients;
|
||||
const [recipient] = envelope.recipients;
|
||||
const { email: recipientEmail, name: recipientName } = recipient;
|
||||
const { user: owner } = document;
|
||||
const { user: owner } = envelope;
|
||||
|
||||
const recipientReference = recipientName || recipientEmail;
|
||||
|
||||
@@ -80,9 +88,9 @@ export const run = async ({
|
||||
emailType: 'INTERNAL',
|
||||
source: {
|
||||
type: 'team',
|
||||
teamId: document.teamId,
|
||||
teamId: envelope.teamId,
|
||||
},
|
||||
meta: document.documentMeta,
|
||||
meta: envelope.documentMeta,
|
||||
});
|
||||
|
||||
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
|
||||
@@ -90,7 +98,7 @@ export const run = async ({
|
||||
const i18n = await getI18nInstance(emailLanguage);
|
||||
|
||||
const template = createElement(DocumentRecipientSignedEmailTemplate, {
|
||||
documentName: document.title,
|
||||
documentName: envelope.title,
|
||||
recipientName,
|
||||
recipientEmail,
|
||||
assetBaseUrl,
|
||||
@@ -112,7 +120,7 @@ export const run = async ({
|
||||
address: owner.email,
|
||||
},
|
||||
from: senderEmail,
|
||||
subject: i18n._(msg`${recipientReference} has signed "${document.title}"`),
|
||||
subject: i18n._(msg`${recipientReference} has signed "${envelope.title}"`),
|
||||
html,
|
||||
text,
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createElement } from 'react';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { SendStatus, SigningStatus } from '@prisma/client';
|
||||
import { EnvelopeType, SendStatus, SigningStatus } from '@prisma/client';
|
||||
|
||||
import { mailer } from '@documenso/email/mailer';
|
||||
import DocumentRejectedEmail from '@documenso/email/templates/document-rejected';
|
||||
@@ -13,6 +13,7 @@ import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
|
||||
import { DOCUMENSO_INTERNAL_EMAIL } from '../../../constants/email';
|
||||
import { getEmailContext } from '../../../server-only/email/get-email-context';
|
||||
import { extractDerivedDocumentEmailSettings } from '../../../types/document-email';
|
||||
import { unsafeBuildEnvelopeIdQuery } from '../../../utils/envelope';
|
||||
import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
|
||||
import { formatDocumentsPath } from '../../../utils/teams';
|
||||
import type { JobRunIO } from '../../client/_internal/job';
|
||||
@@ -27,11 +28,15 @@ export const run = async ({
|
||||
}) => {
|
||||
const { documentId, recipientId } = payload;
|
||||
|
||||
const [document, recipient] = await Promise.all([
|
||||
prisma.document.findFirstOrThrow({
|
||||
where: {
|
||||
id: documentId,
|
||||
},
|
||||
const [envelope, recipient] = await Promise.all([
|
||||
prisma.envelope.findFirstOrThrow({
|
||||
where: unsafeBuildEnvelopeIdQuery(
|
||||
{
|
||||
type: 'documentId',
|
||||
id: documentId,
|
||||
},
|
||||
EnvelopeType.DOCUMENT,
|
||||
),
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
@@ -58,10 +63,10 @@ export const run = async ({
|
||||
}),
|
||||
]);
|
||||
|
||||
const { user: documentOwner } = document;
|
||||
const { user: documentOwner } = envelope;
|
||||
|
||||
const isEmailEnabled = extractDerivedDocumentEmailSettings(
|
||||
document.documentMeta,
|
||||
envelope.documentMeta,
|
||||
).recipientSigningRequest;
|
||||
|
||||
if (!isEmailEnabled) {
|
||||
@@ -72,9 +77,9 @@ export const run = async ({
|
||||
emailType: 'RECIPIENT',
|
||||
source: {
|
||||
type: 'team',
|
||||
teamId: document.teamId,
|
||||
teamId: envelope.teamId,
|
||||
},
|
||||
meta: document.documentMeta,
|
||||
meta: envelope.documentMeta,
|
||||
});
|
||||
|
||||
const i18n = await getI18nInstance(emailLanguage);
|
||||
@@ -83,8 +88,8 @@ export const run = async ({
|
||||
await io.runTask('send-rejection-confirmation-email', async () => {
|
||||
const recipientTemplate = createElement(DocumentRejectionConfirmedEmail, {
|
||||
recipientName: recipient.name,
|
||||
documentName: document.title,
|
||||
documentOwnerName: document.user.name || document.user.email,
|
||||
documentName: envelope.title,
|
||||
documentOwnerName: envelope.user.name || envelope.user.email,
|
||||
reason: recipient.rejectionReason || '',
|
||||
assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL(),
|
||||
});
|
||||
@@ -105,7 +110,7 @@ export const run = async ({
|
||||
},
|
||||
from: senderEmail,
|
||||
replyTo: replyToEmail,
|
||||
subject: i18n._(msg`Document "${document.title}" - Rejection Confirmed`),
|
||||
subject: i18n._(msg`Document "${envelope.title}" - Rejection Confirmed`),
|
||||
html,
|
||||
text,
|
||||
});
|
||||
@@ -115,9 +120,9 @@ export const run = async ({
|
||||
await io.runTask('send-owner-notification-email', async () => {
|
||||
const ownerTemplate = createElement(DocumentRejectedEmail, {
|
||||
recipientName: recipient.name,
|
||||
documentName: document.title,
|
||||
documentUrl: `${NEXT_PUBLIC_WEBAPP_URL()}${formatDocumentsPath(document.team?.url)}/${
|
||||
document.id
|
||||
documentName: envelope.title,
|
||||
documentUrl: `${NEXT_PUBLIC_WEBAPP_URL()}${formatDocumentsPath(envelope.team?.url)}/${
|
||||
envelope.id
|
||||
}`,
|
||||
rejectionReason: recipient.rejectionReason || '',
|
||||
assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL(),
|
||||
@@ -138,7 +143,7 @@ export const run = async ({
|
||||
address: documentOwner.email,
|
||||
},
|
||||
from: DOCUMENSO_INTERNAL_EMAIL, // Purposefully using internal email here.
|
||||
subject: i18n._(msg`Document "${document.title}" - Rejected by ${recipient.name}`),
|
||||
subject: i18n._(msg`Document "${envelope.title}" - Rejected by ${recipient.name}`),
|
||||
html,
|
||||
text,
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import { msg } from '@lingui/core/macro';
|
||||
import {
|
||||
DocumentSource,
|
||||
DocumentStatus,
|
||||
EnvelopeType,
|
||||
OrganisationType,
|
||||
RecipientRole,
|
||||
SendStatus,
|
||||
@@ -23,6 +24,7 @@ import { getEmailContext } from '../../../server-only/email/get-email-context';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../../types/document-audit-logs';
|
||||
import { extractDerivedDocumentEmailSettings } from '../../../types/document-email';
|
||||
import { createDocumentAuditLogData } from '../../../utils/document-audit-logs';
|
||||
import { unsafeBuildEnvelopeIdQuery } from '../../../utils/envelope';
|
||||
import { renderCustomEmailTemplate } from '../../../utils/render-custom-email-template';
|
||||
import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
|
||||
import type { JobRunIO } from '../../client/_internal/job';
|
||||
@@ -37,7 +39,7 @@ export const run = async ({
|
||||
}) => {
|
||||
const { userId, documentId, recipientId, requestMetadata } = payload;
|
||||
|
||||
const [user, document, recipient] = await Promise.all([
|
||||
const [user, envelope, recipient] = await Promise.all([
|
||||
prisma.user.findFirstOrThrow({
|
||||
where: {
|
||||
id: userId,
|
||||
@@ -48,9 +50,15 @@ export const run = async ({
|
||||
name: true,
|
||||
},
|
||||
}),
|
||||
prisma.document.findFirstOrThrow({
|
||||
prisma.envelope.findFirstOrThrow({
|
||||
where: {
|
||||
id: documentId,
|
||||
...unsafeBuildEnvelopeIdQuery(
|
||||
{
|
||||
type: 'documentId',
|
||||
id: documentId,
|
||||
},
|
||||
EnvelopeType.DOCUMENT,
|
||||
),
|
||||
status: DocumentStatus.PENDING,
|
||||
},
|
||||
include: {
|
||||
@@ -70,14 +78,14 @@ export const run = async ({
|
||||
}),
|
||||
]);
|
||||
|
||||
const { documentMeta, team } = document;
|
||||
const { documentMeta, team } = envelope;
|
||||
|
||||
if (recipient.role === RecipientRole.CC) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isRecipientSigningRequestEmailEnabled = extractDerivedDocumentEmailSettings(
|
||||
document.documentMeta,
|
||||
envelope.documentMeta,
|
||||
).recipientSigningRequest;
|
||||
|
||||
if (!isRecipientSigningRequestEmailEnabled) {
|
||||
@@ -89,13 +97,13 @@ export const run = async ({
|
||||
emailType: 'RECIPIENT',
|
||||
source: {
|
||||
type: 'team',
|
||||
teamId: document.teamId,
|
||||
teamId: envelope.teamId,
|
||||
},
|
||||
meta: document.documentMeta,
|
||||
meta: envelope.documentMeta,
|
||||
});
|
||||
|
||||
const customEmail = document?.documentMeta;
|
||||
const isDirectTemplate = document.source === DocumentSource.TEMPLATE_DIRECT_LINK;
|
||||
const customEmail = envelope?.documentMeta;
|
||||
const isDirectTemplate = envelope.source === DocumentSource.TEMPLATE_DIRECT_LINK;
|
||||
|
||||
const recipientEmailType = RECIPIENT_ROLE_TO_EMAIL_TYPE[recipient.role];
|
||||
|
||||
@@ -113,7 +121,7 @@ export const run = async ({
|
||||
|
||||
if (selfSigner) {
|
||||
emailMessage = i18n._(
|
||||
msg`You have initiated the document ${`"${document.title}"`} that requires you to ${recipientActionVerb} it.`,
|
||||
msg`You have initiated the document ${`"${envelope.title}"`} that requires you to ${recipientActionVerb} it.`,
|
||||
);
|
||||
emailSubject = i18n._(msg`Please ${recipientActionVerb} your document`);
|
||||
}
|
||||
@@ -136,8 +144,8 @@ export const run = async ({
|
||||
|
||||
emailMessage = i18n._(
|
||||
settings.includeSenderDetails
|
||||
? msg`${inviterName} on behalf of "${team.name}" has invited you to ${recipientActionVerb} the document "${document.title}".`
|
||||
: msg`${team.name} has invited you to ${recipientActionVerb} the document "${document.title}".`,
|
||||
? msg`${inviterName} on behalf of "${team.name}" has invited you to ${recipientActionVerb} the document "${envelope.title}".`
|
||||
: msg`${team.name} has invited you to ${recipientActionVerb} the document "${envelope.title}".`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -145,14 +153,14 @@ export const run = async ({
|
||||
const customEmailTemplate = {
|
||||
'signer.name': name,
|
||||
'signer.email': email,
|
||||
'document.name': document.title,
|
||||
'document.name': envelope.title,
|
||||
};
|
||||
|
||||
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
|
||||
const signDocumentLink = `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`;
|
||||
|
||||
const template = createElement(DocumentInviteEmailTemplate, {
|
||||
documentName: document.title,
|
||||
documentName: envelope.title,
|
||||
inviterName: user.name || undefined,
|
||||
inviterEmail:
|
||||
organisationType === OrganisationType.ORGANISATION
|
||||
@@ -210,7 +218,7 @@ export const run = async ({
|
||||
await prisma.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
|
||||
documentId: document.id,
|
||||
envelopeId: envelope.id,
|
||||
user,
|
||||
requestMetadata,
|
||||
data: {
|
||||
|
||||
@@ -37,7 +37,10 @@ export const run = async ({
|
||||
const { userId, teamId, templateId, csvContent, sendImmediately, requestMetadata } = payload;
|
||||
|
||||
const template = await getTemplateById({
|
||||
id: templateId,
|
||||
id: {
|
||||
type: 'templateId',
|
||||
id: templateId,
|
||||
},
|
||||
userId,
|
||||
teamId,
|
||||
});
|
||||
@@ -99,9 +102,12 @@ export const run = async ({
|
||||
}
|
||||
}
|
||||
|
||||
const document = await io.runTask(`create-document-${rowIndex}`, async () => {
|
||||
const envelope = await io.runTask(`create-document-${rowIndex}`, async () => {
|
||||
return await createDocumentFromTemplate({
|
||||
templateId: template.id,
|
||||
id: {
|
||||
type: 'templateId',
|
||||
id: template.id,
|
||||
},
|
||||
userId,
|
||||
teamId,
|
||||
recipients: recipients.map((recipient, index) => {
|
||||
@@ -124,7 +130,10 @@ export const run = async ({
|
||||
if (sendImmediately) {
|
||||
await io.runTask(`send-document-${rowIndex}`, async () => {
|
||||
await sendDocument({
|
||||
documentId: document.id,
|
||||
id: {
|
||||
type: 'envelopeId',
|
||||
id: envelope.id,
|
||||
},
|
||||
userId,
|
||||
teamId,
|
||||
requestMetadata: {
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { DocumentStatus, RecipientRole, SigningStatus, WebhookTriggerEvents } from '@prisma/client';
|
||||
import { PDFDocument } from '@cantoo/pdf-lib';
|
||||
import type { DocumentData, DocumentMeta, Envelope, EnvelopeItem, Field } from '@prisma/client';
|
||||
import {
|
||||
DocumentStatus,
|
||||
EnvelopeType,
|
||||
RecipientRole,
|
||||
SigningStatus,
|
||||
WebhookTriggerEvents,
|
||||
} from '@prisma/client';
|
||||
import { nanoid } from 'nanoid';
|
||||
import path from 'node:path';
|
||||
import { PDFDocument } from 'pdf-lib';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { signPdf } from '@documenso/signing';
|
||||
@@ -14,7 +21,8 @@ import { getCertificatePdf } from '../../../server-only/htmltopdf/get-certificat
|
||||
import { addRejectionStampToPdf } from '../../../server-only/pdf/add-rejection-stamp-to-pdf';
|
||||
import { flattenAnnotations } from '../../../server-only/pdf/flatten-annotations';
|
||||
import { flattenForm } from '../../../server-only/pdf/flatten-form';
|
||||
import { insertFieldInPDF } from '../../../server-only/pdf/insert-field-in-pdf';
|
||||
import { insertFieldInPDFV1 } from '../../../server-only/pdf/insert-field-in-pdf-v1';
|
||||
import { insertFieldInPDFV2 } from '../../../server-only/pdf/insert-field-in-pdf-v2';
|
||||
import { legacy_insertFieldInPDF } from '../../../server-only/pdf/legacy-insert-field-in-pdf';
|
||||
import { normalizeSignatureAppearances } from '../../../server-only/pdf/normalize-signature-appearances';
|
||||
import { getTeamSettings } from '../../../server-only/team/get-team-settings';
|
||||
@@ -22,7 +30,7 @@ import { triggerWebhook } from '../../../server-only/webhooks/trigger/trigger-we
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../../types/document-audit-logs';
|
||||
import {
|
||||
ZWebhookDocumentSchema,
|
||||
mapDocumentToWebhookDocumentPayload,
|
||||
mapEnvelopeToWebhookDocumentPayload,
|
||||
} from '../../../types/webhook-payload';
|
||||
import { prefixedId } from '../../../universal/id';
|
||||
import { getFileServerSide } from '../../../universal/upload/get-file.server';
|
||||
@@ -30,6 +38,7 @@ import { putPdfFileServerSide } from '../../../universal/upload/put-file.server'
|
||||
import { fieldsContainUnsignedRequiredField } from '../../../utils/advanced-fields-helpers';
|
||||
import { isDocumentCompleted } from '../../../utils/document';
|
||||
import { createDocumentAuditLogData } from '../../../utils/document-audit-logs';
|
||||
import { mapDocumentIdToSecondaryId, mapSecondaryIdToDocumentId } from '../../../utils/envelope';
|
||||
import type { JobRunIO } from '../../client/_internal/job';
|
||||
import type { TSealDocumentJobDefinition } from './seal-document';
|
||||
|
||||
@@ -42,24 +51,39 @@ export const run = async ({
|
||||
}) => {
|
||||
const { documentId, sendEmail = true, isResealing = false, requestMetadata } = payload;
|
||||
|
||||
const document = await prisma.document.findFirstOrThrow({
|
||||
const envelope = await prisma.envelope.findFirstOrThrow({
|
||||
where: {
|
||||
id: documentId,
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
secondaryId: mapDocumentIdToSecondaryId(documentId),
|
||||
},
|
||||
include: {
|
||||
documentMeta: true,
|
||||
recipients: true,
|
||||
envelopeItems: {
|
||||
include: {
|
||||
documentData: true,
|
||||
field: {
|
||||
include: {
|
||||
signature: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (envelope.envelopeItems.length === 0) {
|
||||
throw new Error('At least one envelope item required');
|
||||
}
|
||||
|
||||
const settings = await getTeamSettings({
|
||||
userId: document.userId,
|
||||
teamId: document.teamId,
|
||||
userId: envelope.userId,
|
||||
teamId: envelope.teamId,
|
||||
});
|
||||
|
||||
const isComplete =
|
||||
document.recipients.some((recipient) => recipient.signingStatus === SigningStatus.REJECTED) ||
|
||||
document.recipients.every((recipient) => recipient.signingStatus === SigningStatus.SIGNED);
|
||||
envelope.recipients.some((recipient) => recipient.signingStatus === SigningStatus.REJECTED) ||
|
||||
envelope.recipients.every((recipient) => recipient.signingStatus === SigningStatus.SIGNED);
|
||||
|
||||
if (!isComplete) {
|
||||
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
|
||||
@@ -71,28 +95,28 @@ export const run = async ({
|
||||
// after it has already run through the update task further below.
|
||||
// eslint-disable-next-line @typescript-eslint/require-await
|
||||
const documentStatus = await io.runTask('get-document-status', async () => {
|
||||
return document.status;
|
||||
return envelope.status;
|
||||
});
|
||||
|
||||
// This is the same case as above.
|
||||
// eslint-disable-next-line @typescript-eslint/require-await
|
||||
const documentDataId = await io.runTask('get-document-data-id', async () => {
|
||||
return document.documentDataId;
|
||||
});
|
||||
|
||||
const documentData = await prisma.documentData.findFirst({
|
||||
where: {
|
||||
id: documentDataId,
|
||||
let envelopeItems = await io.runTask(
|
||||
'get-document-data-id',
|
||||
// eslint-disable-next-line @typescript-eslint/require-await
|
||||
async () => {
|
||||
// eslint-disable-next-line unused-imports/no-unused-vars
|
||||
return envelope.envelopeItems.map(({ field, ...rest }) => ({
|
||||
...rest,
|
||||
}));
|
||||
},
|
||||
});
|
||||
);
|
||||
|
||||
if (!documentData) {
|
||||
throw new Error(`Document ${document.id} has no document data`);
|
||||
if (envelopeItems.length < 1) {
|
||||
throw new Error(`Document ${envelope.id} has no envelope items`);
|
||||
}
|
||||
|
||||
const recipients = await prisma.recipient.findMany({
|
||||
where: {
|
||||
documentId: document.id,
|
||||
envelopeId: envelope.id,
|
||||
role: {
|
||||
not: RecipientRole.CC,
|
||||
},
|
||||
@@ -111,7 +135,7 @@ export const run = async ({
|
||||
|
||||
const fields = await prisma.field.findMany({
|
||||
where: {
|
||||
documentId: document.id,
|
||||
envelopeId: envelope.id,
|
||||
},
|
||||
include: {
|
||||
signature: true,
|
||||
@@ -120,19 +144,25 @@ export const run = async ({
|
||||
|
||||
// Skip the field check if the document is rejected
|
||||
if (!isRejected && fieldsContainUnsignedRequiredField(fields)) {
|
||||
throw new Error(`Document ${document.id} has unsigned required fields`);
|
||||
throw new Error(`Document ${envelope.id} has unsigned required fields`);
|
||||
}
|
||||
|
||||
if (isResealing) {
|
||||
// If we're resealing we want to use the initial data for the document
|
||||
// so we aren't placing fields on top of eachother.
|
||||
documentData.data = documentData.initialData;
|
||||
envelopeItems = envelopeItems.map((envelopeItem) => ({
|
||||
...envelopeItem,
|
||||
documentData: {
|
||||
...envelopeItem.documentData,
|
||||
data: envelopeItem.documentData.initialData,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
if (!document.qrToken) {
|
||||
await prisma.document.update({
|
||||
if (!envelope.qrToken) {
|
||||
await prisma.envelope.update({
|
||||
where: {
|
||||
id: document.id,
|
||||
id: envelope.id,
|
||||
},
|
||||
data: {
|
||||
qrToken: prefixedId('qr'),
|
||||
@@ -140,97 +170,38 @@ export const run = async ({
|
||||
});
|
||||
}
|
||||
|
||||
const pdfData = await getFileServerSide(documentData);
|
||||
const legacyDocumentId = mapSecondaryIdToDocumentId(envelope.secondaryId);
|
||||
|
||||
const certificateData = settings.includeSigningCertificate
|
||||
? await getCertificatePdf({
|
||||
documentId,
|
||||
language: document.documentMeta?.language,
|
||||
}).catch((e) => {
|
||||
console.log('Failed to get certificate PDF');
|
||||
console.error(e);
|
||||
|
||||
return null;
|
||||
})
|
||||
: null;
|
||||
|
||||
const auditLogData = settings.includeAuditLog
|
||||
? await getAuditLogsPdf({
|
||||
documentId,
|
||||
language: document.documentMeta?.language,
|
||||
}).catch((e) => {
|
||||
console.log('Failed to get audit logs PDF');
|
||||
console.error(e);
|
||||
|
||||
return null;
|
||||
})
|
||||
: null;
|
||||
|
||||
const newDataId = await io.runTask('decorate-and-sign-pdf', async () => {
|
||||
const pdfDoc = await PDFDocument.load(pdfData);
|
||||
|
||||
// Normalize and flatten layers that could cause issues with the signature
|
||||
normalizeSignatureAppearances(pdfDoc);
|
||||
await flattenForm(pdfDoc);
|
||||
flattenAnnotations(pdfDoc);
|
||||
|
||||
// Add rejection stamp if the document is rejected
|
||||
if (isRejected && rejectionReason) {
|
||||
await addRejectionStampToPdf(pdfDoc, rejectionReason);
|
||||
}
|
||||
|
||||
if (certificateData) {
|
||||
const certificateDoc = await PDFDocument.load(certificateData);
|
||||
|
||||
const certificatePages = await pdfDoc.copyPages(
|
||||
certificateDoc,
|
||||
certificateDoc.getPageIndices(),
|
||||
);
|
||||
|
||||
certificatePages.forEach((page) => {
|
||||
pdfDoc.addPage(page);
|
||||
});
|
||||
}
|
||||
|
||||
if (auditLogData) {
|
||||
const auditLogDoc = await PDFDocument.load(auditLogData);
|
||||
|
||||
const auditLogPages = await pdfDoc.copyPages(auditLogDoc, auditLogDoc.getPageIndices());
|
||||
|
||||
auditLogPages.forEach((page) => {
|
||||
pdfDoc.addPage(page);
|
||||
});
|
||||
}
|
||||
|
||||
for (const field of fields) {
|
||||
if (field.inserted) {
|
||||
document.useLegacyFieldInsertion
|
||||
? await legacy_insertFieldInPDF(pdfDoc, field)
|
||||
: await insertFieldInPDF(pdfDoc, field);
|
||||
}
|
||||
}
|
||||
|
||||
// Re-flatten the form to handle our checkbox and radio fields that
|
||||
// create native arcoFields
|
||||
await flattenForm(pdfDoc);
|
||||
|
||||
const pdfBytes = await pdfDoc.save();
|
||||
const pdfBuffer = await signPdf({ pdf: Buffer.from(pdfBytes) });
|
||||
|
||||
const { name } = path.parse(document.title);
|
||||
|
||||
// Add suffix based on document status
|
||||
const suffix = isRejected ? '_rejected.pdf' : '_signed.pdf';
|
||||
|
||||
const documentData = await putPdfFileServerSide({
|
||||
name: `${name}${suffix}`,
|
||||
type: 'application/pdf',
|
||||
arrayBuffer: async () => Promise.resolve(pdfBuffer),
|
||||
});
|
||||
|
||||
return documentData.id;
|
||||
const { certificateData, auditLogData } = await getCertificateAndAuditLogData({
|
||||
legacyDocumentId,
|
||||
documentMeta: envelope.documentMeta,
|
||||
settings,
|
||||
});
|
||||
|
||||
const newDocumentData = await Promise.all(
|
||||
envelopeItems.map(async (envelopeItem) =>
|
||||
io.runTask('decorate-and-sign-pdf', async () => {
|
||||
const envelopeItemFields = envelope.envelopeItems.find(
|
||||
(item) => item.id === envelopeItem.id,
|
||||
)?.field;
|
||||
|
||||
if (!envelopeItemFields) {
|
||||
throw new Error(`Envelope item fields not found for envelope item ${envelopeItem.id}`);
|
||||
}
|
||||
|
||||
return decorateAndSignPdf({
|
||||
envelope,
|
||||
envelopeItem,
|
||||
envelopeItemFields,
|
||||
isRejected,
|
||||
rejectionReason,
|
||||
certificateData,
|
||||
auditLogData,
|
||||
});
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const postHog = PostHogServerClient();
|
||||
|
||||
if (postHog) {
|
||||
@@ -238,7 +209,7 @@ export const run = async ({
|
||||
distinctId: nanoid(),
|
||||
event: 'App: Document Sealed',
|
||||
properties: {
|
||||
documentId: document.id,
|
||||
documentId: envelope.id,
|
||||
isRejected,
|
||||
},
|
||||
});
|
||||
@@ -246,15 +217,26 @@ export const run = async ({
|
||||
|
||||
await io.runTask('update-document', async () => {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
const newData = await tx.documentData.findFirstOrThrow({
|
||||
where: {
|
||||
id: newDataId,
|
||||
},
|
||||
});
|
||||
for (const { oldDocumentDataId, newDocumentDataId } of newDocumentData) {
|
||||
const newData = await tx.documentData.findFirstOrThrow({
|
||||
where: {
|
||||
id: newDocumentDataId,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.document.update({
|
||||
await tx.documentData.update({
|
||||
where: {
|
||||
id: oldDocumentDataId,
|
||||
},
|
||||
data: {
|
||||
data: newData.data,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await tx.envelope.update({
|
||||
where: {
|
||||
id: document.id,
|
||||
id: envelope.id,
|
||||
},
|
||||
data: {
|
||||
status: isRejected ? DocumentStatus.REJECTED : DocumentStatus.COMPLETED,
|
||||
@@ -262,19 +244,10 @@ export const run = async ({
|
||||
},
|
||||
});
|
||||
|
||||
await tx.documentData.update({
|
||||
where: {
|
||||
id: documentData.id,
|
||||
},
|
||||
data: {
|
||||
data: newData.data,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_COMPLETED,
|
||||
documentId: document.id,
|
||||
envelopeId: envelope.id,
|
||||
requestMetadata,
|
||||
user: null,
|
||||
data: {
|
||||
@@ -289,21 +262,23 @@ export const run = async ({
|
||||
await io.runTask('send-completed-email', async () => {
|
||||
let shouldSendCompletedEmail = sendEmail && !isResealing && !isRejected;
|
||||
|
||||
if (isResealing && !isDocumentCompleted(document.status)) {
|
||||
if (isResealing && !isDocumentCompleted(envelope.status)) {
|
||||
shouldSendCompletedEmail = sendEmail;
|
||||
}
|
||||
|
||||
if (shouldSendCompletedEmail) {
|
||||
await sendCompletedEmail({ documentId, requestMetadata });
|
||||
await sendCompletedEmail({
|
||||
id: { type: 'envelopeId', id: envelope.id },
|
||||
requestMetadata,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const updatedDocument = await prisma.document.findFirstOrThrow({
|
||||
const updatedEnvelope = await prisma.envelope.findFirstOrThrow({
|
||||
where: {
|
||||
id: document.id,
|
||||
id: envelope.id,
|
||||
},
|
||||
include: {
|
||||
documentData: true,
|
||||
documentMeta: true,
|
||||
recipients: true,
|
||||
},
|
||||
@@ -313,8 +288,148 @@ export const run = async ({
|
||||
event: isRejected
|
||||
? WebhookTriggerEvents.DOCUMENT_REJECTED
|
||||
: WebhookTriggerEvents.DOCUMENT_COMPLETED,
|
||||
data: ZWebhookDocumentSchema.parse(mapDocumentToWebhookDocumentPayload(updatedDocument)),
|
||||
userId: updatedDocument.userId,
|
||||
teamId: updatedDocument.teamId ?? undefined,
|
||||
data: ZWebhookDocumentSchema.parse(mapEnvelopeToWebhookDocumentPayload(updatedEnvelope)),
|
||||
userId: updatedEnvelope.userId,
|
||||
teamId: updatedEnvelope.teamId ?? undefined,
|
||||
});
|
||||
};
|
||||
|
||||
type DecorateAndSignPdfOptions = {
|
||||
envelope: Pick<Envelope, 'id' | 'title' | 'useLegacyFieldInsertion' | 'internalVersion'>;
|
||||
envelopeItem: EnvelopeItem & { documentData: DocumentData };
|
||||
envelopeItemFields: Field[];
|
||||
isRejected: boolean;
|
||||
rejectionReason: string;
|
||||
certificateData: Buffer | null;
|
||||
auditLogData: Buffer | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch, normalize, flatten and insert fields into a PDF document.
|
||||
*/
|
||||
const decorateAndSignPdf = async ({
|
||||
envelope,
|
||||
envelopeItem,
|
||||
envelopeItemFields,
|
||||
isRejected,
|
||||
rejectionReason,
|
||||
certificateData,
|
||||
auditLogData,
|
||||
}: DecorateAndSignPdfOptions) => {
|
||||
const pdfData = await getFileServerSide(envelopeItem.documentData);
|
||||
|
||||
const pdfDoc = await PDFDocument.load(pdfData);
|
||||
|
||||
// Normalize and flatten layers that could cause issues with the signature
|
||||
normalizeSignatureAppearances(pdfDoc);
|
||||
await flattenForm(pdfDoc);
|
||||
flattenAnnotations(pdfDoc);
|
||||
|
||||
// Add rejection stamp if the document is rejected
|
||||
if (isRejected && rejectionReason) {
|
||||
await addRejectionStampToPdf(pdfDoc, rejectionReason);
|
||||
}
|
||||
|
||||
if (certificateData) {
|
||||
const certificateDoc = await PDFDocument.load(certificateData);
|
||||
|
||||
const certificatePages = await pdfDoc.copyPages(
|
||||
certificateDoc,
|
||||
certificateDoc.getPageIndices(),
|
||||
);
|
||||
|
||||
certificatePages.forEach((page) => {
|
||||
pdfDoc.addPage(page);
|
||||
});
|
||||
}
|
||||
|
||||
if (auditLogData) {
|
||||
const auditLogDoc = await PDFDocument.load(auditLogData);
|
||||
|
||||
const auditLogPages = await pdfDoc.copyPages(auditLogDoc, auditLogDoc.getPageIndices());
|
||||
|
||||
auditLogPages.forEach((page) => {
|
||||
pdfDoc.addPage(page);
|
||||
});
|
||||
}
|
||||
|
||||
for (const field of envelopeItemFields) {
|
||||
if (field.inserted) {
|
||||
if (envelope.internalVersion === 2) {
|
||||
await insertFieldInPDFV2(pdfDoc, field);
|
||||
} else if (envelope.useLegacyFieldInsertion) {
|
||||
await legacy_insertFieldInPDF(pdfDoc, field);
|
||||
} else {
|
||||
await insertFieldInPDFV1(pdfDoc, field);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Re-flatten the form to handle our checkbox and radio fields that
|
||||
// create native arcoFields
|
||||
await flattenForm(pdfDoc);
|
||||
|
||||
const pdfBytes = await pdfDoc.save();
|
||||
|
||||
const pdfBuffer = await signPdf({ pdf: Buffer.from(pdfBytes) });
|
||||
|
||||
const { name } = path.parse(envelopeItem.title);
|
||||
|
||||
// Add suffix based on document status
|
||||
const suffix = isRejected ? '_rejected.pdf' : '_signed.pdf';
|
||||
|
||||
const newDocumentData = await putPdfFileServerSide({
|
||||
name: `${name}${suffix}`,
|
||||
type: 'application/pdf',
|
||||
arrayBuffer: async () => Promise.resolve(pdfBuffer),
|
||||
});
|
||||
|
||||
return {
|
||||
oldDocumentDataId: envelopeItem.documentData.id,
|
||||
newDocumentDataId: newDocumentData.id,
|
||||
};
|
||||
};
|
||||
|
||||
export const getCertificateAndAuditLogData = async ({
|
||||
legacyDocumentId,
|
||||
documentMeta,
|
||||
settings,
|
||||
}: {
|
||||
legacyDocumentId: number;
|
||||
documentMeta: DocumentMeta;
|
||||
settings: { includeSigningCertificate: boolean; includeAuditLog: boolean };
|
||||
}) => {
|
||||
const getCertificateDataPromise = settings.includeSigningCertificate
|
||||
? getCertificatePdf({
|
||||
documentId: legacyDocumentId,
|
||||
language: documentMeta.language,
|
||||
}).catch((e) => {
|
||||
console.log('Failed to get certificate PDF');
|
||||
console.error(e);
|
||||
|
||||
return null;
|
||||
})
|
||||
: null;
|
||||
|
||||
const getAuditLogDataPromise = settings.includeAuditLog
|
||||
? getAuditLogsPdf({
|
||||
documentId: legacyDocumentId,
|
||||
language: documentMeta.language,
|
||||
}).catch((e) => {
|
||||
console.log('Failed to get audit logs PDF');
|
||||
console.error(e);
|
||||
|
||||
return null;
|
||||
})
|
||||
: null;
|
||||
|
||||
const [certificateData, auditLogData] = await Promise.all([
|
||||
getCertificateDataPromise,
|
||||
getAuditLogDataPromise,
|
||||
]);
|
||||
|
||||
return {
|
||||
certificateData,
|
||||
auditLogData,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -43,7 +43,6 @@
|
||||
"micro": "^10.0.1",
|
||||
"nanoid": "^5.1.5",
|
||||
"oslo": "^0.17.0",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"pg": "^8.11.3",
|
||||
"pino": "^9.7.0",
|
||||
"pino-pretty": "^13.0.0",
|
||||
@@ -53,6 +52,7 @@
|
||||
"react": "^18",
|
||||
"remeda": "^2.17.3",
|
||||
"sharp": "0.32.6",
|
||||
"skia-canvas": "^3.0.8",
|
||||
"stripe": "^12.7.0",
|
||||
"ts-pattern": "^5.0.5",
|
||||
"zod": "3.24.1"
|
||||
|
||||
@@ -7,7 +7,7 @@ import { DOCUMENSO_ENCRYPTION_KEY } from '../../../constants/crypto';
|
||||
const ISSUER = 'Documenso Email 2FA';
|
||||
|
||||
export type GenerateTwoFactorCredentialsFromEmailOptions = {
|
||||
documentId: number;
|
||||
envelopeId: string;
|
||||
email: string;
|
||||
};
|
||||
|
||||
@@ -18,14 +18,14 @@ export type GenerateTwoFactorCredentialsFromEmailOptions = {
|
||||
* @returns Object containing the token and the 6-digit code
|
||||
*/
|
||||
export const generateTwoFactorCredentialsFromEmail = ({
|
||||
documentId,
|
||||
envelopeId,
|
||||
email,
|
||||
}: GenerateTwoFactorCredentialsFromEmailOptions) => {
|
||||
if (!DOCUMENSO_ENCRYPTION_KEY) {
|
||||
throw new Error('Missing DOCUMENSO_ENCRYPTION_KEY');
|
||||
}
|
||||
|
||||
const identity = `email-2fa|v1|email:${email}|id:${documentId}`;
|
||||
const identity = `email-2fa|v1|email:${email}|id:${envelopeId}`;
|
||||
|
||||
const secret = hmac(sha256, DOCUMENSO_ENCRYPTION_KEY, identity);
|
||||
|
||||
|
||||
@@ -3,17 +3,17 @@ import { generateHOTP } from 'oslo/otp';
|
||||
import { generateTwoFactorCredentialsFromEmail } from './generate-2fa-credentials-from-email';
|
||||
|
||||
export type GenerateTwoFactorTokenFromEmailOptions = {
|
||||
documentId: number;
|
||||
envelopeId: string;
|
||||
email: string;
|
||||
period?: number;
|
||||
};
|
||||
|
||||
export const generateTwoFactorTokenFromEmail = async ({
|
||||
email,
|
||||
documentId,
|
||||
envelopeId,
|
||||
period = 30_000,
|
||||
}: GenerateTwoFactorTokenFromEmailOptions) => {
|
||||
const { secret } = generateTwoFactorCredentialsFromEmail({ email, documentId });
|
||||
const { secret } = generateTwoFactorCredentialsFromEmail({ email, envelopeId });
|
||||
|
||||
const counter = Math.floor(Date.now() / period);
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createElement } from 'react';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { EnvelopeType } from '@prisma/client';
|
||||
|
||||
import { mailer } from '@documenso/email/mailer';
|
||||
import { AccessAuth2FAEmailTemplate } from '@documenso/email/templates/access-auth-2fa';
|
||||
@@ -11,6 +12,7 @@ import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
|
||||
import { AppError, AppErrorCode } from '../../../errors/app-error';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../../types/document-audit-logs';
|
||||
import { createDocumentAuditLogData } from '../../../utils/document-audit-logs';
|
||||
import { unsafeBuildEnvelopeIdQuery } from '../../../utils/envelope';
|
||||
import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
|
||||
import { getEmailContext } from '../../email/get-email-context';
|
||||
import { TWO_FACTOR_EMAIL_EXPIRATION_MINUTES } from './constants';
|
||||
@@ -18,13 +20,19 @@ import { generateTwoFactorTokenFromEmail } from './generate-2fa-token-from-email
|
||||
|
||||
export type Send2FATokenEmailOptions = {
|
||||
token: string;
|
||||
documentId: number;
|
||||
envelopeId: string;
|
||||
};
|
||||
|
||||
export const send2FATokenEmail = async ({ token, documentId }: Send2FATokenEmailOptions) => {
|
||||
const document = await prisma.document.findFirst({
|
||||
export const send2FATokenEmail = async ({ token, envelopeId }: Send2FATokenEmailOptions) => {
|
||||
const envelope = await prisma.envelope.findFirst({
|
||||
where: {
|
||||
id: documentId,
|
||||
...unsafeBuildEnvelopeIdQuery(
|
||||
{
|
||||
type: 'envelopeId',
|
||||
id: envelopeId,
|
||||
},
|
||||
EnvelopeType.DOCUMENT,
|
||||
),
|
||||
recipients: {
|
||||
some: {
|
||||
token,
|
||||
@@ -47,13 +55,13 @@ export const send2FATokenEmail = async ({ token, documentId }: Send2FATokenEmail
|
||||
},
|
||||
});
|
||||
|
||||
if (!document) {
|
||||
if (!envelope) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Document not found',
|
||||
});
|
||||
}
|
||||
|
||||
const [recipient] = document.recipients;
|
||||
const [recipient] = envelope.recipients;
|
||||
|
||||
if (!recipient) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
@@ -62,7 +70,7 @@ export const send2FATokenEmail = async ({ token, documentId }: Send2FATokenEmail
|
||||
}
|
||||
|
||||
const twoFactorTokenToken = await generateTwoFactorTokenFromEmail({
|
||||
documentId,
|
||||
envelopeId,
|
||||
email: recipient.email,
|
||||
});
|
||||
|
||||
@@ -70,9 +78,9 @@ export const send2FATokenEmail = async ({ token, documentId }: Send2FATokenEmail
|
||||
emailType: 'RECIPIENT',
|
||||
source: {
|
||||
type: 'team',
|
||||
teamId: document.teamId,
|
||||
teamId: envelope.teamId,
|
||||
},
|
||||
meta: document.documentMeta,
|
||||
meta: envelope.documentMeta,
|
||||
});
|
||||
|
||||
const i18n = await getI18nInstance(emailLanguage);
|
||||
@@ -80,7 +88,7 @@ export const send2FATokenEmail = async ({ token, documentId }: Send2FATokenEmail
|
||||
const subject = i18n._(msg`Your two-factor authentication code`);
|
||||
|
||||
const template = createElement(AccessAuth2FAEmailTemplate, {
|
||||
documentTitle: document.title,
|
||||
documentTitle: envelope.title,
|
||||
userName: recipient.name,
|
||||
userEmail: recipient.email,
|
||||
code: twoFactorTokenToken,
|
||||
@@ -110,7 +118,7 @@ export const send2FATokenEmail = async ({ token, documentId }: Send2FATokenEmail
|
||||
await tx.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_ACCESS_AUTH_2FA_REQUESTED,
|
||||
documentId: document.id,
|
||||
envelopeId: envelope.id,
|
||||
data: {
|
||||
recipientEmail: recipient.email,
|
||||
recipientName: recipient.name,
|
||||
|
||||
@@ -3,7 +3,7 @@ import { generateHOTP } from 'oslo/otp';
|
||||
import { generateTwoFactorCredentialsFromEmail } from './generate-2fa-credentials-from-email';
|
||||
|
||||
export type ValidateTwoFactorTokenFromEmailOptions = {
|
||||
documentId: number;
|
||||
envelopeId: string;
|
||||
email: string;
|
||||
code: string;
|
||||
period?: number;
|
||||
@@ -11,13 +11,13 @@ export type ValidateTwoFactorTokenFromEmailOptions = {
|
||||
};
|
||||
|
||||
export const validateTwoFactorTokenFromEmail = async ({
|
||||
documentId,
|
||||
envelopeId,
|
||||
email,
|
||||
code,
|
||||
period = 30_000,
|
||||
window = 1,
|
||||
}: ValidateTwoFactorTokenFromEmailOptions) => {
|
||||
const { secret } = generateTwoFactorCredentialsFromEmail({ email, documentId });
|
||||
const { secret } = generateTwoFactorCredentialsFromEmail({ email, envelopeId });
|
||||
|
||||
let now = Date.now();
|
||||
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { EnvelopeType, type Prisma } from '@prisma/client';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import type { FindResultResponse } from '../../types/search-params';
|
||||
|
||||
export interface AdminFindDocumentsOptions {
|
||||
query?: string;
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
}
|
||||
|
||||
export const adminFindDocuments = async ({
|
||||
query,
|
||||
page = 1,
|
||||
perPage = 10,
|
||||
}: AdminFindDocumentsOptions) => {
|
||||
let termFilters: Prisma.EnvelopeWhereInput | undefined = !query
|
||||
? undefined
|
||||
: {
|
||||
title: {
|
||||
contains: query,
|
||||
mode: 'insensitive',
|
||||
},
|
||||
};
|
||||
|
||||
if (query && query.startsWith('envelope_')) {
|
||||
termFilters = {
|
||||
id: {
|
||||
equals: query,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (query && query.startsWith('document_')) {
|
||||
termFilters = {
|
||||
secondaryId: {
|
||||
equals: query,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (query) {
|
||||
const isQueryAnInteger = !isNaN(parseInt(query));
|
||||
|
||||
if (isQueryAnInteger) {
|
||||
termFilters = {
|
||||
secondaryId: {
|
||||
equals: `document_${query}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const [data, count] = await Promise.all([
|
||||
prisma.envelope.findMany({
|
||||
where: {
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
...termFilters,
|
||||
},
|
||||
skip: Math.max(page - 1, 0) * perPage,
|
||||
take: perPage,
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
recipients: true,
|
||||
team: {
|
||||
select: {
|
||||
id: true,
|
||||
url: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
prisma.envelope.count({
|
||||
where: {
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
...termFilters,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
data,
|
||||
count,
|
||||
currentPage: Math.max(page, 1),
|
||||
perPage,
|
||||
totalPages: Math.ceil(count / perPage),
|
||||
} satisfies FindResultResponse<typeof data>;
|
||||
};
|
||||
+19
-16
@@ -17,15 +17,18 @@ import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
|
||||
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
|
||||
import { getEmailContext } from '../email/get-email-context';
|
||||
|
||||
export type SuperDeleteDocumentOptions = {
|
||||
id: number;
|
||||
export type AdminSuperDeleteDocumentOptions = {
|
||||
envelopeId: string;
|
||||
requestMetadata?: RequestMetadata;
|
||||
};
|
||||
|
||||
export const superDeleteDocument = async ({ id, requestMetadata }: SuperDeleteDocumentOptions) => {
|
||||
const document = await prisma.document.findUnique({
|
||||
export const adminSuperDeleteDocument = async ({
|
||||
envelopeId,
|
||||
requestMetadata,
|
||||
}: AdminSuperDeleteDocumentOptions) => {
|
||||
const envelope = await prisma.envelope.findUnique({
|
||||
where: {
|
||||
id,
|
||||
id: envelopeId,
|
||||
},
|
||||
include: {
|
||||
recipients: true,
|
||||
@@ -40,7 +43,7 @@ export const superDeleteDocument = async ({ id, requestMetadata }: SuperDeleteDo
|
||||
},
|
||||
});
|
||||
|
||||
if (!document) {
|
||||
if (!envelope) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Document not found',
|
||||
});
|
||||
@@ -50,38 +53,38 @@ export const superDeleteDocument = async ({ id, requestMetadata }: SuperDeleteDo
|
||||
emailType: 'RECIPIENT',
|
||||
source: {
|
||||
type: 'team',
|
||||
teamId: document.teamId,
|
||||
teamId: envelope.teamId,
|
||||
},
|
||||
meta: document.documentMeta,
|
||||
meta: envelope.documentMeta,
|
||||
});
|
||||
|
||||
const { status, user } = document;
|
||||
const { status, user } = envelope;
|
||||
|
||||
const isDocumentDeletedEmailEnabled = extractDerivedDocumentEmailSettings(
|
||||
document.documentMeta,
|
||||
envelope.documentMeta,
|
||||
).documentDeleted;
|
||||
|
||||
// if the document is pending, send cancellation emails to all recipients
|
||||
if (
|
||||
status === DocumentStatus.PENDING &&
|
||||
document.recipients.length > 0 &&
|
||||
envelope.recipients.length > 0 &&
|
||||
isDocumentDeletedEmailEnabled
|
||||
) {
|
||||
await Promise.all(
|
||||
document.recipients.map(async (recipient) => {
|
||||
envelope.recipients.map(async (recipient) => {
|
||||
if (recipient.sendStatus !== SendStatus.SENT) {
|
||||
return;
|
||||
}
|
||||
|
||||
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
|
||||
const template = createElement(DocumentCancelTemplate, {
|
||||
documentName: document.title,
|
||||
documentName: envelope.title,
|
||||
inviterName: user.name || undefined,
|
||||
inviterEmail: user.email,
|
||||
assetBaseUrl,
|
||||
});
|
||||
|
||||
const lang = document.documentMeta?.language ?? settings.documentLanguage;
|
||||
const lang = envelope.documentMeta?.language ?? settings.documentLanguage;
|
||||
|
||||
const [html, text] = await Promise.all([
|
||||
renderEmailWithI18N(template, { lang, branding }),
|
||||
@@ -113,7 +116,7 @@ export const superDeleteDocument = async ({ id, requestMetadata }: SuperDeleteDo
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
await tx.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
documentId: id,
|
||||
envelopeId,
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELETED,
|
||||
user,
|
||||
requestMetadata,
|
||||
@@ -123,6 +126,6 @@ export const superDeleteDocument = async ({ id, requestMetadata }: SuperDeleteDo
|
||||
}),
|
||||
});
|
||||
|
||||
return await tx.document.delete({ where: { id } });
|
||||
return await tx.envelope.delete({ where: { id: envelopeId } });
|
||||
});
|
||||
};
|
||||
@@ -1,58 +0,0 @@
|
||||
import type { Prisma } from '@prisma/client';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import type { FindResultResponse } from '../../types/search-params';
|
||||
|
||||
export interface FindDocumentsOptions {
|
||||
query?: string;
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
}
|
||||
|
||||
export const findDocuments = async ({ query, page = 1, perPage = 10 }: FindDocumentsOptions) => {
|
||||
const termFilters: Prisma.DocumentWhereInput | undefined = !query
|
||||
? undefined
|
||||
: {
|
||||
title: {
|
||||
contains: query,
|
||||
mode: 'insensitive',
|
||||
},
|
||||
};
|
||||
|
||||
const [data, count] = await Promise.all([
|
||||
prisma.document.findMany({
|
||||
where: {
|
||||
...termFilters,
|
||||
},
|
||||
skip: Math.max(page - 1, 0) * perPage,
|
||||
take: perPage,
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
recipients: true,
|
||||
},
|
||||
}),
|
||||
prisma.document.count({
|
||||
where: {
|
||||
...termFilters,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
data,
|
||||
count,
|
||||
currentPage: Math.max(page, 1),
|
||||
perPage,
|
||||
totalPages: Math.ceil(count / perPage),
|
||||
} satisfies FindResultResponse<typeof data>;
|
||||
};
|
||||
@@ -1,8 +1,13 @@
|
||||
import { EnvelopeType } from '@prisma/client';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
|
||||
|
||||
export const getDocumentStats = async () => {
|
||||
const counts = await prisma.document.groupBy({
|
||||
const counts = await prisma.envelope.groupBy({
|
||||
where: {
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
},
|
||||
by: ['status'],
|
||||
_count: {
|
||||
_all: true,
|
||||
|
||||
@@ -1,14 +1,22 @@
|
||||
import type { EnvelopeType } from '@prisma/client';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
export type GetEntireDocumentOptions = {
|
||||
id: number;
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import type { EnvelopeIdOptions } from '../../utils/envelope';
|
||||
import { unsafeBuildEnvelopeIdQuery } from '../../utils/envelope';
|
||||
|
||||
export type unsafeGetEntireEnvelopeOptions = {
|
||||
id: EnvelopeIdOptions;
|
||||
type: EnvelopeType;
|
||||
};
|
||||
|
||||
export const getEntireDocument = async ({ id }: GetEntireDocumentOptions) => {
|
||||
const document = await prisma.document.findFirstOrThrow({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
/**
|
||||
* An unauthenticated function that returns the whole envelope
|
||||
*/
|
||||
export const unsafeGetEntireEnvelope = async ({ id, type }: unsafeGetEntireEnvelopeOptions) => {
|
||||
const envelope = await prisma.envelope.findFirst({
|
||||
where: unsafeBuildEnvelopeIdQuery(id, type),
|
||||
include: {
|
||||
documentMeta: true,
|
||||
user: {
|
||||
@@ -30,5 +38,11 @@ export const getEntireDocument = async ({ id }: GetEntireDocumentOptions) => {
|
||||
},
|
||||
});
|
||||
|
||||
return document;
|
||||
if (!envelope) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Envelope not found',
|
||||
});
|
||||
}
|
||||
|
||||
return envelope;
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { DocumentStatus, SubscriptionStatus } from '@prisma/client';
|
||||
import { DocumentStatus, EnvelopeType, SubscriptionStatus } from '@prisma/client';
|
||||
|
||||
import { kyselyPrisma, sql } from '@documenso/prisma';
|
||||
|
||||
@@ -31,22 +31,23 @@ export async function getSigningVolume({
|
||||
.selectFrom('Subscription as s')
|
||||
.innerJoin('Organisation as o', 's.organisationId', 'o.id')
|
||||
.leftJoin('Team as t', 'o.id', 't.organisationId')
|
||||
.leftJoin('Document as d', (join) =>
|
||||
.leftJoin('Envelope as e', (join) =>
|
||||
join
|
||||
.onRef('t.id', '=', 'd.teamId')
|
||||
.on('d.status', '=', sql.lit(DocumentStatus.COMPLETED))
|
||||
.on('d.deletedAt', 'is', null),
|
||||
.onRef('t.id', '=', 'e.teamId')
|
||||
.on('e.status', '=', sql.lit(DocumentStatus.COMPLETED))
|
||||
.on('e.deletedAt', 'is', null),
|
||||
)
|
||||
.where(sql`s.status = ${SubscriptionStatus.ACTIVE}::"SubscriptionStatus"`)
|
||||
.where((eb) =>
|
||||
eb.or([eb('o.name', 'ilike', `%${search}%`), eb('t.name', 'ilike', `%${search}%`)]),
|
||||
)
|
||||
.where('e.type', '=', EnvelopeType.DOCUMENT)
|
||||
.select([
|
||||
's.id as id',
|
||||
's.createdAt as createdAt',
|
||||
's.planId as planId',
|
||||
sql<string>`COALESCE(o.name, 'Unknown')`.as('name'),
|
||||
sql<number>`COUNT(DISTINCT d.id)`.as('signingVolume'),
|
||||
sql<number>`COUNT(DISTINCT e.id)`.as('signingVolume'),
|
||||
])
|
||||
.groupBy(['s.id', 'o.name']);
|
||||
|
||||
|
||||
@@ -32,12 +32,13 @@ type GetUserWithDocumentMonthlyGrowthQueryResult = Array<{
|
||||
export const getUserWithSignedDocumentMonthlyGrowth = async () => {
|
||||
const result = await prisma.$queryRaw<GetUserWithDocumentMonthlyGrowthQueryResult>`
|
||||
SELECT
|
||||
DATE_TRUNC('month', "Document"."createdAt") AS "month",
|
||||
COUNT(DISTINCT "Document"."userId") as "count",
|
||||
COUNT(DISTINCT CASE WHEN "Document"."status" = 'COMPLETED' THEN "Document"."userId" END) as "signed_count"
|
||||
FROM "Document"
|
||||
INNER JOIN "Team" ON "Document"."teamId" = "Team"."id"
|
||||
DATE_TRUNC('month', "Envelope"."createdAt") AS "month",
|
||||
COUNT(DISTINCT "Envelope"."userId") as "count",
|
||||
COUNT(DISTINCT CASE WHEN "Envelope"."status" = 'COMPLETED' THEN "Envelope"."userId" END) as "signed_count"
|
||||
FROM "Envelope"
|
||||
INNER JOIN "Team" ON "Envelope"."teamId" = "Team"."id"
|
||||
INNER JOIN "Organisation" ON "Team"."organisationId" = "Organisation"."id"
|
||||
WHERE "Envelope"."type" = 'DOCUMENT'::"EnvelopeType"
|
||||
GROUP BY "month"
|
||||
ORDER BY "month" DESC
|
||||
LIMIT 12
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import type { DocumentDistributionMethod, DocumentSigningOrder } from '@prisma/client';
|
||||
import {
|
||||
type DocumentDistributionMethod,
|
||||
type DocumentSigningOrder,
|
||||
EnvelopeType,
|
||||
} from '@prisma/client';
|
||||
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
||||
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||
@@ -11,16 +15,16 @@ import { prisma } from '@documenso/prisma';
|
||||
import type { SupportedLanguageCodes } from '../../constants/i18n';
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import type { TDocumentEmailSettings } from '../../types/document-email';
|
||||
import { getDocumentWhereInput } from '../document/get-document-by-id';
|
||||
import type { EnvelopeIdOptions } from '../../utils/envelope';
|
||||
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
|
||||
|
||||
export type CreateDocumentMetaOptions = {
|
||||
userId: number;
|
||||
teamId: number;
|
||||
documentId: number;
|
||||
id: EnvelopeIdOptions;
|
||||
subject?: string;
|
||||
message?: string;
|
||||
timezone?: string;
|
||||
password?: string;
|
||||
dateFormat?: string;
|
||||
redirectUrl?: string;
|
||||
emailId?: string | null;
|
||||
@@ -36,15 +40,14 @@ export type CreateDocumentMetaOptions = {
|
||||
requestMetadata: ApiRequestMetadata;
|
||||
};
|
||||
|
||||
export const upsertDocumentMeta = async ({
|
||||
export const updateDocumentMeta = async ({
|
||||
id,
|
||||
userId,
|
||||
teamId,
|
||||
subject,
|
||||
message,
|
||||
timezone,
|
||||
dateFormat,
|
||||
documentId,
|
||||
password,
|
||||
redirectUrl,
|
||||
signingOrder,
|
||||
allowDictateNextSigner,
|
||||
@@ -58,26 +61,27 @@ export const upsertDocumentMeta = async ({
|
||||
language,
|
||||
requestMetadata,
|
||||
}: CreateDocumentMetaOptions) => {
|
||||
const { documentWhereInput, team } = await getDocumentWhereInput({
|
||||
documentId,
|
||||
const { envelopeWhereInput, team } = await getEnvelopeWhereInput({
|
||||
id,
|
||||
type: null, // Allow updating both documents and templates meta.
|
||||
userId,
|
||||
teamId,
|
||||
});
|
||||
|
||||
const document = await prisma.document.findFirst({
|
||||
where: documentWhereInput,
|
||||
const envelope = await prisma.envelope.findFirst({
|
||||
where: envelopeWhereInput,
|
||||
include: {
|
||||
documentMeta: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!document) {
|
||||
if (!envelope) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Document not found',
|
||||
});
|
||||
}
|
||||
|
||||
const { documentMeta: originalDocumentMeta } = document;
|
||||
const { documentMeta: originalDocumentMeta } = envelope;
|
||||
|
||||
// Validate the emailId belongs to the organisation.
|
||||
if (emailId) {
|
||||
@@ -96,33 +100,13 @@ export const upsertDocumentMeta = async ({
|
||||
}
|
||||
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
const upsertedDocumentMeta = await tx.documentMeta.upsert({
|
||||
const upsertedDocumentMeta = await tx.documentMeta.update({
|
||||
where: {
|
||||
documentId,
|
||||
id: envelope.documentMetaId,
|
||||
},
|
||||
create: {
|
||||
data: {
|
||||
subject,
|
||||
message,
|
||||
password,
|
||||
dateFormat,
|
||||
timezone,
|
||||
documentId,
|
||||
redirectUrl,
|
||||
signingOrder,
|
||||
allowDictateNextSigner,
|
||||
emailId,
|
||||
emailReplyTo,
|
||||
emailSettings,
|
||||
distributionMethod,
|
||||
typedSignatureEnabled,
|
||||
uploadSignatureEnabled,
|
||||
drawSignatureEnabled,
|
||||
language,
|
||||
},
|
||||
update: {
|
||||
subject,
|
||||
message,
|
||||
password,
|
||||
dateFormat,
|
||||
timezone,
|
||||
redirectUrl,
|
||||
@@ -141,11 +125,12 @@ export const upsertDocumentMeta = async ({
|
||||
|
||||
const changes = diffDocumentMetaChanges(originalDocumentMeta ?? {}, upsertedDocumentMeta);
|
||||
|
||||
if (changes.length > 0) {
|
||||
// Create audit logs only for document type envelopes.
|
||||
if (changes.length > 0 && envelope.type === EnvelopeType.DOCUMENT) {
|
||||
await tx.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_META_UPDATED,
|
||||
documentId,
|
||||
envelopeId: envelope.id,
|
||||
metadata: requestMetadata,
|
||||
data: {
|
||||
changes: diffDocumentMetaChanges(originalDocumentMeta ?? {}, upsertedDocumentMeta),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
DocumentSigningOrder,
|
||||
DocumentStatus,
|
||||
EnvelopeType,
|
||||
RecipientRole,
|
||||
SendStatus,
|
||||
SigningStatus,
|
||||
@@ -22,9 +23,11 @@ import type { TRecipientAccessAuth, TRecipientActionAuth } from '../../types/doc
|
||||
import { DocumentAuth } from '../../types/document-auth';
|
||||
import {
|
||||
ZWebhookDocumentSchema,
|
||||
mapDocumentToWebhookDocumentPayload,
|
||||
mapEnvelopeToWebhookDocumentPayload,
|
||||
} from '../../types/webhook-payload';
|
||||
import { extractDocumentAuthMethods } from '../../utils/document-auth';
|
||||
import type { EnvelopeIdOptions } from '../../utils/envelope';
|
||||
import { mapSecondaryIdToDocumentId, unsafeBuildEnvelopeIdQuery } from '../../utils/envelope';
|
||||
import { getIsRecipientsTurnToSign } from '../recipient/get-is-recipient-turn';
|
||||
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||
import { isRecipientAuthorized } from './is-recipient-authorized';
|
||||
@@ -32,7 +35,7 @@ import { sendPendingEmail } from './send-pending-email';
|
||||
|
||||
export type CompleteDocumentWithTokenOptions = {
|
||||
token: string;
|
||||
documentId: number;
|
||||
id: EnvelopeIdOptions;
|
||||
userId?: number;
|
||||
authOptions?: TRecipientActionAuth;
|
||||
accessAuthOptions?: TRecipientAccessAuth;
|
||||
@@ -43,10 +46,17 @@ export type CompleteDocumentWithTokenOptions = {
|
||||
};
|
||||
};
|
||||
|
||||
const getDocument = async ({ token, documentId }: CompleteDocumentWithTokenOptions) => {
|
||||
return await prisma.document.findFirstOrThrow({
|
||||
export const completeDocumentWithToken = async ({
|
||||
token,
|
||||
id,
|
||||
userId,
|
||||
accessAuthOptions,
|
||||
requestMetadata,
|
||||
nextSigner,
|
||||
}: CompleteDocumentWithTokenOptions) => {
|
||||
const envelope = await prisma.envelope.findFirstOrThrow({
|
||||
where: {
|
||||
id: documentId,
|
||||
...unsafeBuildEnvelopeIdQuery(id, EnvelopeType.DOCUMENT),
|
||||
recipients: {
|
||||
some: {
|
||||
token,
|
||||
@@ -62,27 +72,18 @@ const getDocument = async ({ token, documentId }: CompleteDocumentWithTokenOptio
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const completeDocumentWithToken = async ({
|
||||
token,
|
||||
documentId,
|
||||
userId,
|
||||
accessAuthOptions,
|
||||
requestMetadata,
|
||||
nextSigner,
|
||||
}: CompleteDocumentWithTokenOptions) => {
|
||||
const document = await getDocument({ token, documentId });
|
||||
const legacyDocumentId = mapSecondaryIdToDocumentId(envelope.secondaryId);
|
||||
|
||||
if (document.status !== DocumentStatus.PENDING) {
|
||||
throw new Error(`Document ${document.id} must be pending`);
|
||||
if (envelope.status !== DocumentStatus.PENDING) {
|
||||
throw new Error(`Document ${envelope.id} must be pending`);
|
||||
}
|
||||
|
||||
if (document.recipients.length === 0) {
|
||||
throw new Error(`Document ${document.id} has no recipient with token ${token}`);
|
||||
if (envelope.recipients.length === 0) {
|
||||
throw new Error(`Document ${envelope.id} has no recipient with token ${token}`);
|
||||
}
|
||||
|
||||
const [recipient] = document.recipients;
|
||||
const [recipient] = envelope.recipients;
|
||||
|
||||
if (recipient.signingStatus === SigningStatus.SIGNED) {
|
||||
throw new Error(`Recipient ${recipient.id} has already signed`);
|
||||
@@ -95,7 +96,7 @@ export const completeDocumentWithToken = async ({
|
||||
});
|
||||
}
|
||||
|
||||
if (document.documentMeta?.signingOrder === DocumentSigningOrder.SEQUENTIAL) {
|
||||
if (envelope.documentMeta?.signingOrder === DocumentSigningOrder.SEQUENTIAL) {
|
||||
const isRecipientsTurn = await getIsRecipientsTurnToSign({ token: recipient.token });
|
||||
|
||||
if (!isRecipientsTurn) {
|
||||
@@ -107,7 +108,7 @@ export const completeDocumentWithToken = async ({
|
||||
|
||||
const fields = await prisma.field.findMany({
|
||||
where: {
|
||||
documentId: document.id,
|
||||
envelopeId: envelope.id,
|
||||
recipientId: recipient.id,
|
||||
},
|
||||
});
|
||||
@@ -118,7 +119,7 @@ export const completeDocumentWithToken = async ({
|
||||
|
||||
// Check ACCESS AUTH 2FA validation during document completion
|
||||
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
|
||||
documentAuth: document.authOptions,
|
||||
documentAuth: envelope.authOptions,
|
||||
recipientAuth: recipient.authOptions,
|
||||
});
|
||||
|
||||
@@ -131,7 +132,7 @@ export const completeDocumentWithToken = async ({
|
||||
|
||||
const isValid = await isRecipientAuthorized({
|
||||
type: 'ACCESS_2FA',
|
||||
documentAuthOptions: document.authOptions,
|
||||
documentAuthOptions: envelope.authOptions,
|
||||
recipient: recipient,
|
||||
userId, // Can be undefined for non-account recipients
|
||||
authOptions: accessAuthOptions,
|
||||
@@ -141,7 +142,7 @@ export const completeDocumentWithToken = async ({
|
||||
await prisma.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_ACCESS_AUTH_2FA_FAILED,
|
||||
documentId: document.id,
|
||||
envelopeId: envelope.id,
|
||||
data: {
|
||||
recipientId: recipient.id,
|
||||
recipientName: recipient.name,
|
||||
@@ -158,7 +159,7 @@ export const completeDocumentWithToken = async ({
|
||||
await prisma.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_ACCESS_AUTH_2FA_VALIDATED,
|
||||
documentId: document.id,
|
||||
envelopeId: envelope.id,
|
||||
data: {
|
||||
recipientId: recipient.id,
|
||||
recipientName: recipient.name,
|
||||
@@ -180,14 +181,14 @@ export const completeDocumentWithToken = async ({
|
||||
});
|
||||
|
||||
const authOptions = extractDocumentAuthMethods({
|
||||
documentAuth: document.authOptions,
|
||||
documentAuth: envelope.authOptions,
|
||||
recipientAuth: recipient.authOptions,
|
||||
});
|
||||
|
||||
await tx.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED,
|
||||
documentId: document.id,
|
||||
envelopeId: envelope.id,
|
||||
user: {
|
||||
name: recipient.name,
|
||||
email: recipient.email,
|
||||
@@ -207,7 +208,7 @@ export const completeDocumentWithToken = async ({
|
||||
await jobs.triggerJob({
|
||||
name: 'send.recipient.signed.email',
|
||||
payload: {
|
||||
documentId: document.id,
|
||||
documentId: legacyDocumentId,
|
||||
recipientId: recipient.id,
|
||||
},
|
||||
});
|
||||
@@ -221,7 +222,7 @@ export const completeDocumentWithToken = async ({
|
||||
role: true,
|
||||
},
|
||||
where: {
|
||||
documentId: document.id,
|
||||
envelopeId: envelope.id,
|
||||
signingStatus: {
|
||||
not: SigningStatus.SIGNED,
|
||||
},
|
||||
@@ -235,17 +236,17 @@ export const completeDocumentWithToken = async ({
|
||||
});
|
||||
|
||||
if (pendingRecipients.length > 0) {
|
||||
await sendPendingEmail({ documentId, recipientId: recipient.id });
|
||||
await sendPendingEmail({ id, recipientId: recipient.id });
|
||||
|
||||
if (document.documentMeta?.signingOrder === DocumentSigningOrder.SEQUENTIAL) {
|
||||
if (envelope.documentMeta?.signingOrder === DocumentSigningOrder.SEQUENTIAL) {
|
||||
const [nextRecipient] = pendingRecipients;
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
if (nextSigner && document.documentMeta?.allowDictateNextSigner) {
|
||||
if (nextSigner && envelope.documentMeta?.allowDictateNextSigner) {
|
||||
await tx.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_UPDATED,
|
||||
documentId: document.id,
|
||||
envelopeId: envelope.id,
|
||||
user: {
|
||||
name: recipient.name,
|
||||
email: recipient.email,
|
||||
@@ -277,7 +278,7 @@ export const completeDocumentWithToken = async ({
|
||||
where: { id: nextRecipient.id },
|
||||
data: {
|
||||
sendStatus: SendStatus.SENT,
|
||||
...(nextSigner && document.documentMeta?.allowDictateNextSigner
|
||||
...(nextSigner && envelope.documentMeta?.allowDictateNextSigner
|
||||
? {
|
||||
name: nextSigner.name,
|
||||
email: nextSigner.email,
|
||||
@@ -289,8 +290,8 @@ export const completeDocumentWithToken = async ({
|
||||
await jobs.triggerJob({
|
||||
name: 'send.signing.requested.email',
|
||||
payload: {
|
||||
userId: document.userId,
|
||||
documentId: document.id,
|
||||
userId: envelope.userId,
|
||||
documentId: legacyDocumentId,
|
||||
recipientId: nextRecipient.id,
|
||||
requestMetadata,
|
||||
},
|
||||
@@ -299,9 +300,9 @@ export const completeDocumentWithToken = async ({
|
||||
}
|
||||
}
|
||||
|
||||
const haveAllRecipientsSigned = await prisma.document.findFirst({
|
||||
const haveAllRecipientsSigned = await prisma.envelope.findFirst({
|
||||
where: {
|
||||
id: document.id,
|
||||
id: envelope.id,
|
||||
recipients: {
|
||||
every: {
|
||||
OR: [{ signingStatus: SigningStatus.SIGNED }, { role: RecipientRole.CC }],
|
||||
@@ -314,15 +315,16 @@ export const completeDocumentWithToken = async ({
|
||||
await jobs.triggerJob({
|
||||
name: 'internal.seal-document',
|
||||
payload: {
|
||||
documentId: document.id,
|
||||
documentId: legacyDocumentId,
|
||||
requestMetadata,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const updatedDocument = await prisma.document.findFirstOrThrow({
|
||||
const updatedDocument = await prisma.envelope.findFirstOrThrow({
|
||||
where: {
|
||||
id: document.id,
|
||||
id: envelope.id,
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
},
|
||||
include: {
|
||||
documentMeta: true,
|
||||
@@ -332,7 +334,7 @@ export const completeDocumentWithToken = async ({
|
||||
|
||||
await triggerWebhook({
|
||||
event: WebhookTriggerEvents.DOCUMENT_SIGNED,
|
||||
data: ZWebhookDocumentSchema.parse(mapDocumentToWebhookDocumentPayload(updatedDocument)),
|
||||
data: ZWebhookDocumentSchema.parse(mapEnvelopeToWebhookDocumentPayload(updatedDocument)),
|
||||
userId: updatedDocument.userId,
|
||||
teamId: updatedDocument.teamId ?? undefined,
|
||||
});
|
||||
|
||||
@@ -1,278 +0,0 @@
|
||||
import type { DocumentVisibility, TemplateMeta } from '@prisma/client';
|
||||
import {
|
||||
DocumentSource,
|
||||
FolderType,
|
||||
RecipientRole,
|
||||
SendStatus,
|
||||
SigningStatus,
|
||||
WebhookTriggerEvents,
|
||||
} from '@prisma/client';
|
||||
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { normalizePdf as makeNormalizedPdf } from '@documenso/lib/server-only/pdf/normalize-pdf';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
||||
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||
import { nanoid, prefixedId } from '@documenso/lib/universal/id';
|
||||
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import type { TCreateDocumentTemporaryRequest } from '@documenso/trpc/server/document-router/create-document-temporary.types';
|
||||
|
||||
import type { TDocumentAccessAuthTypes, TDocumentActionAuthTypes } from '../../types/document-auth';
|
||||
import type { TDocumentFormValues } from '../../types/document-form-values';
|
||||
import {
|
||||
ZWebhookDocumentSchema,
|
||||
mapDocumentToWebhookDocumentPayload,
|
||||
} from '../../types/webhook-payload';
|
||||
import { getFileServerSide } from '../../universal/upload/get-file.server';
|
||||
import { putPdfFileServerSide } from '../../universal/upload/put-file.server';
|
||||
import { extractDerivedDocumentMeta } from '../../utils/document';
|
||||
import { createDocumentAuthOptions, createRecipientAuthOptions } from '../../utils/document-auth';
|
||||
import { determineDocumentVisibility } from '../../utils/document-visibility';
|
||||
import { buildTeamWhereQuery } from '../../utils/teams';
|
||||
import { getMemberRoles } from '../team/get-member-roles';
|
||||
import { getTeamSettings } from '../team/get-team-settings';
|
||||
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||
|
||||
export type CreateDocumentOptions = {
|
||||
userId: number;
|
||||
teamId: number;
|
||||
documentDataId: string;
|
||||
normalizePdf?: boolean;
|
||||
data: {
|
||||
title: string;
|
||||
externalId?: string;
|
||||
visibility?: DocumentVisibility;
|
||||
globalAccessAuth?: TDocumentAccessAuthTypes[];
|
||||
globalActionAuth?: TDocumentActionAuthTypes[];
|
||||
formValues?: TDocumentFormValues;
|
||||
recipients: TCreateDocumentTemporaryRequest['recipients'];
|
||||
folderId?: string;
|
||||
};
|
||||
meta?: Partial<Omit<TemplateMeta, 'id' | 'templateId'>>;
|
||||
requestMetadata: ApiRequestMetadata;
|
||||
};
|
||||
|
||||
export const createDocumentV2 = async ({
|
||||
userId,
|
||||
teamId,
|
||||
documentDataId,
|
||||
normalizePdf,
|
||||
data,
|
||||
meta,
|
||||
requestMetadata,
|
||||
}: CreateDocumentOptions) => {
|
||||
const { title, formValues, folderId } = data;
|
||||
|
||||
const team = await prisma.team.findFirst({
|
||||
where: buildTeamWhereQuery({ teamId, userId }),
|
||||
include: {
|
||||
organisation: {
|
||||
select: {
|
||||
organisationClaim: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!team) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Team not found',
|
||||
});
|
||||
}
|
||||
|
||||
if (folderId) {
|
||||
const folder = await prisma.folder.findUnique({
|
||||
where: {
|
||||
id: folderId,
|
||||
type: FolderType.DOCUMENT,
|
||||
team: buildTeamWhereQuery({ teamId, userId }),
|
||||
},
|
||||
});
|
||||
|
||||
if (!folder) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Folder not found',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const settings = await getTeamSettings({
|
||||
userId,
|
||||
teamId,
|
||||
});
|
||||
|
||||
if (normalizePdf) {
|
||||
const documentData = await prisma.documentData.findFirst({
|
||||
where: {
|
||||
id: documentDataId,
|
||||
},
|
||||
});
|
||||
|
||||
if (documentData) {
|
||||
const buffer = await getFileServerSide(documentData);
|
||||
|
||||
const normalizedPdf = await makeNormalizedPdf(Buffer.from(buffer));
|
||||
|
||||
const newDocumentData = await putPdfFileServerSide({
|
||||
name: title.endsWith('.pdf') ? title : `${title}.pdf`,
|
||||
type: 'application/pdf',
|
||||
arrayBuffer: async () => Promise.resolve(normalizedPdf),
|
||||
});
|
||||
|
||||
// eslint-disable-next-line require-atomic-updates
|
||||
documentDataId = newDocumentData.id;
|
||||
}
|
||||
}
|
||||
|
||||
const authOptions = createDocumentAuthOptions({
|
||||
globalAccessAuth: data?.globalAccessAuth || [],
|
||||
globalActionAuth: data?.globalActionAuth || [],
|
||||
});
|
||||
|
||||
const recipientsHaveActionAuth = data.recipients?.some(
|
||||
(recipient) => recipient.actionAuth && recipient.actionAuth.length > 0,
|
||||
);
|
||||
|
||||
// Check if user has permission to set the global action auth.
|
||||
if (
|
||||
(authOptions.globalActionAuth.length > 0 || recipientsHaveActionAuth) &&
|
||||
!team.organisation.organisationClaim.flags.cfr21
|
||||
) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'You do not have permission to set the action auth',
|
||||
});
|
||||
}
|
||||
|
||||
const { teamRole } = await getMemberRoles({
|
||||
teamId,
|
||||
reference: {
|
||||
type: 'User',
|
||||
id: userId,
|
||||
},
|
||||
});
|
||||
|
||||
const visibility = determineDocumentVisibility(settings.documentVisibility, teamRole);
|
||||
|
||||
const emailId = meta?.emailId;
|
||||
|
||||
// Validate that the email ID belongs to the organisation.
|
||||
if (emailId) {
|
||||
const email = await prisma.organisationEmail.findFirst({
|
||||
where: {
|
||||
id: emailId,
|
||||
organisationId: team.organisationId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!email) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Email not found',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
const document = await tx.document.create({
|
||||
data: {
|
||||
title,
|
||||
qrToken: prefixedId('qr'),
|
||||
externalId: data.externalId,
|
||||
documentDataId,
|
||||
userId,
|
||||
teamId,
|
||||
authOptions,
|
||||
visibility,
|
||||
folderId,
|
||||
formValues,
|
||||
source: DocumentSource.DOCUMENT,
|
||||
documentMeta: {
|
||||
create: extractDerivedDocumentMeta(settings, meta),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await Promise.all(
|
||||
(data.recipients || []).map(async (recipient) => {
|
||||
const recipientAuthOptions = createRecipientAuthOptions({
|
||||
accessAuth: recipient.accessAuth ?? [],
|
||||
actionAuth: recipient.actionAuth ?? [],
|
||||
});
|
||||
|
||||
await tx.recipient.create({
|
||||
data: {
|
||||
documentId: document.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: recipientAuthOptions,
|
||||
fields: {
|
||||
createMany: {
|
||||
data: (recipient.fields || []).map((field) => ({
|
||||
documentId: document.id,
|
||||
type: field.type,
|
||||
page: field.pageNumber,
|
||||
positionX: field.pageX,
|
||||
positionY: field.pageY,
|
||||
width: field.width,
|
||||
height: field.height,
|
||||
customText: '',
|
||||
inserted: false,
|
||||
fieldMeta: field.fieldMeta,
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
// Todo: Is it necessary to create a full audit logs with all fields and recipients audit logs?
|
||||
|
||||
await tx.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_CREATED,
|
||||
documentId: document.id,
|
||||
metadata: requestMetadata,
|
||||
data: {
|
||||
title,
|
||||
source: {
|
||||
type: DocumentSource.DOCUMENT,
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const createdDocument = await tx.document.findFirst({
|
||||
where: {
|
||||
id: document.id,
|
||||
},
|
||||
include: {
|
||||
documentData: true,
|
||||
documentMeta: true,
|
||||
recipients: true,
|
||||
fields: true,
|
||||
folder: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!createdDocument) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Document not found',
|
||||
});
|
||||
}
|
||||
|
||||
await triggerWebhook({
|
||||
event: WebhookTriggerEvents.DOCUMENT_CREATED,
|
||||
data: ZWebhookDocumentSchema.parse(mapDocumentToWebhookDocumentPayload(createdDocument)),
|
||||
userId,
|
||||
teamId,
|
||||
});
|
||||
|
||||
return createdDocument;
|
||||
});
|
||||
};
|
||||
@@ -1,171 +0,0 @@
|
||||
import { DocumentSource, WebhookTriggerEvents } from '@prisma/client';
|
||||
import type { DocumentVisibility } from '@prisma/client';
|
||||
|
||||
import { normalizePdf as makeNormalizedPdf } from '@documenso/lib/server-only/pdf/normalize-pdf';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
||||
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import {
|
||||
ZWebhookDocumentSchema,
|
||||
mapDocumentToWebhookDocumentPayload,
|
||||
} from '../../types/webhook-payload';
|
||||
import { prefixedId } from '../../universal/id';
|
||||
import { getFileServerSide } from '../../universal/upload/get-file.server';
|
||||
import { putPdfFileServerSide } from '../../universal/upload/put-file.server';
|
||||
import { extractDerivedDocumentMeta } from '../../utils/document';
|
||||
import { determineDocumentVisibility } from '../../utils/document-visibility';
|
||||
import { buildTeamWhereQuery } from '../../utils/teams';
|
||||
import { getTeamById } from '../team/get-team';
|
||||
import { getTeamSettings } from '../team/get-team-settings';
|
||||
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||
|
||||
export type CreateDocumentOptions = {
|
||||
title: string;
|
||||
externalId?: string | null;
|
||||
userId: number;
|
||||
teamId: number;
|
||||
documentDataId: string;
|
||||
formValues?: Record<string, string | number | boolean>;
|
||||
normalizePdf?: boolean;
|
||||
timezone?: string;
|
||||
userTimezone?: string;
|
||||
requestMetadata: ApiRequestMetadata;
|
||||
folderId?: string;
|
||||
};
|
||||
|
||||
export const createDocument = async ({
|
||||
userId,
|
||||
title,
|
||||
externalId,
|
||||
documentDataId,
|
||||
teamId,
|
||||
normalizePdf,
|
||||
formValues,
|
||||
requestMetadata,
|
||||
timezone,
|
||||
userTimezone,
|
||||
folderId,
|
||||
}: CreateDocumentOptions) => {
|
||||
const team = await getTeamById({ userId, teamId });
|
||||
|
||||
const settings = await getTeamSettings({
|
||||
userId,
|
||||
teamId,
|
||||
});
|
||||
|
||||
let folderVisibility: DocumentVisibility | undefined;
|
||||
|
||||
if (folderId) {
|
||||
const folder = await prisma.folder.findFirst({
|
||||
where: {
|
||||
id: folderId,
|
||||
team: buildTeamWhereQuery({
|
||||
teamId,
|
||||
userId,
|
||||
}),
|
||||
},
|
||||
select: {
|
||||
visibility: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!folder) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Folder not found',
|
||||
});
|
||||
}
|
||||
|
||||
folderVisibility = folder.visibility;
|
||||
}
|
||||
|
||||
if (normalizePdf) {
|
||||
const documentData = await prisma.documentData.findFirst({
|
||||
where: {
|
||||
id: documentDataId,
|
||||
},
|
||||
});
|
||||
|
||||
if (documentData) {
|
||||
const buffer = await getFileServerSide(documentData);
|
||||
|
||||
const normalizedPdf = await makeNormalizedPdf(Buffer.from(buffer));
|
||||
|
||||
const newDocumentData = await putPdfFileServerSide({
|
||||
name: title.endsWith('.pdf') ? title : `${title}.pdf`,
|
||||
type: 'application/pdf',
|
||||
arrayBuffer: async () => Promise.resolve(normalizedPdf),
|
||||
});
|
||||
|
||||
// eslint-disable-next-line require-atomic-updates
|
||||
documentDataId = newDocumentData.id;
|
||||
}
|
||||
}
|
||||
|
||||
// userTimezone is last because it's always passed in regardless of the organisation/team settings
|
||||
// for uploads from the frontend
|
||||
const timezoneToUse = timezone || settings.documentTimezone || userTimezone;
|
||||
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
const document = await tx.document.create({
|
||||
data: {
|
||||
title,
|
||||
qrToken: prefixedId('qr'),
|
||||
externalId,
|
||||
documentDataId,
|
||||
userId,
|
||||
teamId,
|
||||
folderId,
|
||||
visibility:
|
||||
folderVisibility ??
|
||||
determineDocumentVisibility(settings.documentVisibility, team.currentTeamRole),
|
||||
formValues,
|
||||
source: DocumentSource.DOCUMENT,
|
||||
documentMeta: {
|
||||
create: extractDerivedDocumentMeta(settings, {
|
||||
timezone: timezoneToUse,
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await tx.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_CREATED,
|
||||
documentId: document.id,
|
||||
metadata: requestMetadata,
|
||||
data: {
|
||||
title,
|
||||
source: {
|
||||
type: DocumentSource.DOCUMENT,
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const createdDocument = await tx.document.findFirst({
|
||||
where: {
|
||||
id: document.id,
|
||||
},
|
||||
include: {
|
||||
documentMeta: true,
|
||||
recipients: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!createdDocument) {
|
||||
throw new Error('Document not found');
|
||||
}
|
||||
|
||||
await triggerWebhook({
|
||||
event: WebhookTriggerEvents.DOCUMENT_CREATED,
|
||||
data: ZWebhookDocumentSchema.parse(mapDocumentToWebhookDocumentPayload(createdDocument)),
|
||||
userId,
|
||||
teamId,
|
||||
});
|
||||
|
||||
return createdDocument;
|
||||
});
|
||||
};
|
||||
@@ -1,8 +1,8 @@
|
||||
import { createElement } from 'react';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import type { Document, DocumentMeta, Recipient, User } from '@prisma/client';
|
||||
import { DocumentStatus, SendStatus, WebhookTriggerEvents } from '@prisma/client';
|
||||
import type { DocumentMeta, Envelope, Recipient, User } from '@prisma/client';
|
||||
import { DocumentStatus, EnvelopeType, SendStatus, WebhookTriggerEvents } from '@prisma/client';
|
||||
|
||||
import { mailer } from '@documenso/email/mailer';
|
||||
import DocumentCancelTemplate from '@documenso/email/templates/document-cancel';
|
||||
@@ -15,18 +15,19 @@ import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
|
||||
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
|
||||
import {
|
||||
ZWebhookDocumentSchema,
|
||||
mapDocumentToWebhookDocumentPayload,
|
||||
mapEnvelopeToWebhookDocumentPayload,
|
||||
} from '../../types/webhook-payload';
|
||||
import type { ApiRequestMetadata } from '../../universal/extract-request-metadata';
|
||||
import { isDocumentCompleted } from '../../utils/document';
|
||||
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
|
||||
import { type EnvelopeIdOptions, unsafeBuildEnvelopeIdQuery } from '../../utils/envelope';
|
||||
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
|
||||
import { getEmailContext } from '../email/get-email-context';
|
||||
import { getMemberRoles } from '../team/get-member-roles';
|
||||
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||
|
||||
export type DeleteDocumentOptions = {
|
||||
id: number;
|
||||
id: EnvelopeIdOptions;
|
||||
userId: number;
|
||||
teamId: number;
|
||||
requestMetadata: ApiRequestMetadata;
|
||||
@@ -50,24 +51,23 @@ export const deleteDocument = async ({
|
||||
});
|
||||
}
|
||||
|
||||
const document = await prisma.document.findUnique({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
// Note: This is an unsafe request, we validate the ownership later in the function.
|
||||
const envelope = await prisma.envelope.findUnique({
|
||||
where: unsafeBuildEnvelopeIdQuery(id, EnvelopeType.DOCUMENT),
|
||||
include: {
|
||||
recipients: true,
|
||||
documentMeta: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!document) {
|
||||
if (!envelope) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Document not found',
|
||||
});
|
||||
}
|
||||
|
||||
const isUserTeamMember = await getMemberRoles({
|
||||
teamId: document.teamId,
|
||||
teamId: envelope.teamId,
|
||||
reference: {
|
||||
type: 'User',
|
||||
id: userId,
|
||||
@@ -76,8 +76,8 @@ export const deleteDocument = async ({
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
|
||||
const isUserOwner = document.userId === userId;
|
||||
const userRecipient = document.recipients.find((recipient) => recipient.email === user.email);
|
||||
const isUserOwner = envelope.userId === userId;
|
||||
const userRecipient = envelope.recipients.find((recipient) => recipient.email === user.email);
|
||||
|
||||
if (!isUserOwner && !isUserTeamMember && !userRecipient) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
@@ -88,7 +88,7 @@ export const deleteDocument = async ({
|
||||
// Handle hard or soft deleting the actual document if user has permission.
|
||||
if (isUserOwner || isUserTeamMember) {
|
||||
await handleDocumentOwnerDelete({
|
||||
document,
|
||||
envelope,
|
||||
user,
|
||||
requestMetadata,
|
||||
});
|
||||
@@ -113,27 +113,16 @@ export const deleteDocument = async ({
|
||||
|
||||
await triggerWebhook({
|
||||
event: WebhookTriggerEvents.DOCUMENT_CANCELLED,
|
||||
data: ZWebhookDocumentSchema.parse(mapDocumentToWebhookDocumentPayload(document)),
|
||||
data: ZWebhookDocumentSchema.parse(mapEnvelopeToWebhookDocumentPayload(envelope)),
|
||||
userId,
|
||||
teamId,
|
||||
});
|
||||
|
||||
// Return partial document for API v1 response.
|
||||
return {
|
||||
id: document.id,
|
||||
userId: document.userId,
|
||||
teamId: document.teamId,
|
||||
title: document.title,
|
||||
status: document.status,
|
||||
documentDataId: document.documentDataId,
|
||||
createdAt: document.createdAt,
|
||||
updatedAt: document.updatedAt,
|
||||
completedAt: document.completedAt,
|
||||
};
|
||||
return envelope;
|
||||
};
|
||||
|
||||
type HandleDocumentOwnerDeleteOptions = {
|
||||
document: Document & {
|
||||
envelope: Envelope & {
|
||||
recipients: Recipient[];
|
||||
documentMeta: DocumentMeta | null;
|
||||
};
|
||||
@@ -142,11 +131,11 @@ type HandleDocumentOwnerDeleteOptions = {
|
||||
};
|
||||
|
||||
const handleDocumentOwnerDelete = async ({
|
||||
document,
|
||||
envelope,
|
||||
user,
|
||||
requestMetadata,
|
||||
}: HandleDocumentOwnerDeleteOptions) => {
|
||||
if (document.deletedAt) {
|
||||
if (envelope.deletedAt) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -154,17 +143,17 @@ const handleDocumentOwnerDelete = async ({
|
||||
emailType: 'RECIPIENT',
|
||||
source: {
|
||||
type: 'team',
|
||||
teamId: document.teamId,
|
||||
teamId: envelope.teamId,
|
||||
},
|
||||
meta: document.documentMeta,
|
||||
meta: envelope.documentMeta,
|
||||
});
|
||||
|
||||
// Soft delete completed documents.
|
||||
if (isDocumentCompleted(document.status)) {
|
||||
if (isDocumentCompleted(envelope.status)) {
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
await tx.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
documentId: document.id,
|
||||
envelopeId: envelope.id,
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELETED,
|
||||
metadata: requestMetadata,
|
||||
data: {
|
||||
@@ -173,9 +162,9 @@ const handleDocumentOwnerDelete = async ({
|
||||
}),
|
||||
});
|
||||
|
||||
return await tx.document.update({
|
||||
return await tx.envelope.update({
|
||||
where: {
|
||||
id: document.id,
|
||||
id: envelope.id,
|
||||
},
|
||||
data: {
|
||||
deletedAt: new Date().toISOString(),
|
||||
@@ -185,12 +174,12 @@ const handleDocumentOwnerDelete = async ({
|
||||
}
|
||||
|
||||
// Hard delete draft and pending documents.
|
||||
const deletedDocument = await prisma.$transaction(async (tx) => {
|
||||
const deletedEnvelope = await prisma.$transaction(async (tx) => {
|
||||
// Currently redundant since deleting a document will delete the audit logs.
|
||||
// However may be useful if we disassociate audit logs and documents if required.
|
||||
await tx.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
documentId: document.id,
|
||||
envelopeId: envelope.id,
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELETED,
|
||||
metadata: requestMetadata,
|
||||
data: {
|
||||
@@ -199,9 +188,9 @@ const handleDocumentOwnerDelete = async ({
|
||||
}),
|
||||
});
|
||||
|
||||
return await tx.document.delete({
|
||||
return await tx.envelope.delete({
|
||||
where: {
|
||||
id: document.id,
|
||||
id: envelope.id,
|
||||
status: {
|
||||
not: DocumentStatus.COMPLETED,
|
||||
},
|
||||
@@ -209,17 +198,17 @@ const handleDocumentOwnerDelete = async ({
|
||||
});
|
||||
});
|
||||
|
||||
const isDocumentDeleteEmailEnabled = extractDerivedDocumentEmailSettings(
|
||||
document.documentMeta,
|
||||
const isEnvelopeDeleteEmailEnabled = extractDerivedDocumentEmailSettings(
|
||||
envelope.documentMeta,
|
||||
).documentDeleted;
|
||||
|
||||
if (!isDocumentDeleteEmailEnabled) {
|
||||
return deletedDocument;
|
||||
if (!isEnvelopeDeleteEmailEnabled) {
|
||||
return deletedEnvelope;
|
||||
}
|
||||
|
||||
// Send cancellation emails to recipients.
|
||||
await Promise.all(
|
||||
document.recipients.map(async (recipient) => {
|
||||
envelope.recipients.map(async (recipient) => {
|
||||
if (recipient.sendStatus !== SendStatus.SENT) {
|
||||
return;
|
||||
}
|
||||
@@ -227,7 +216,7 @@ const handleDocumentOwnerDelete = async ({
|
||||
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
|
||||
|
||||
const template = createElement(DocumentCancelTemplate, {
|
||||
documentName: document.title,
|
||||
documentName: envelope.title,
|
||||
inviterName: user.name || undefined,
|
||||
inviterEmail: user.email,
|
||||
assetBaseUrl,
|
||||
@@ -258,5 +247,5 @@ const handleDocumentOwnerDelete = async ({
|
||||
}),
|
||||
);
|
||||
|
||||
return deletedDocument;
|
||||
return deletedEnvelope;
|
||||
};
|
||||
|
||||
@@ -1,152 +0,0 @@
|
||||
import type { Prisma, Recipient } from '@prisma/client';
|
||||
import { DocumentSource, WebhookTriggerEvents } from '@prisma/client';
|
||||
import { omit } from 'remeda';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import {
|
||||
ZWebhookDocumentSchema,
|
||||
mapDocumentToWebhookDocumentPayload,
|
||||
} from '../../types/webhook-payload';
|
||||
import { nanoid, prefixedId } from '../../universal/id';
|
||||
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||
import { getDocumentWhereInput } from './get-document-by-id';
|
||||
|
||||
export interface DuplicateDocumentOptions {
|
||||
documentId: number;
|
||||
userId: number;
|
||||
teamId: number;
|
||||
}
|
||||
|
||||
export const duplicateDocument = async ({
|
||||
documentId,
|
||||
userId,
|
||||
teamId,
|
||||
}: DuplicateDocumentOptions) => {
|
||||
const { documentWhereInput } = await getDocumentWhereInput({
|
||||
documentId,
|
||||
userId,
|
||||
teamId,
|
||||
});
|
||||
|
||||
const document = await prisma.document.findFirst({
|
||||
where: documentWhereInput,
|
||||
select: {
|
||||
title: true,
|
||||
userId: true,
|
||||
documentData: {
|
||||
select: {
|
||||
data: true,
|
||||
initialData: true,
|
||||
type: true,
|
||||
},
|
||||
},
|
||||
authOptions: true,
|
||||
visibility: true,
|
||||
documentMeta: true,
|
||||
recipients: {
|
||||
select: {
|
||||
email: true,
|
||||
name: true,
|
||||
role: true,
|
||||
signingOrder: true,
|
||||
fields: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!document) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Document not found',
|
||||
});
|
||||
}
|
||||
|
||||
const documentData = await prisma.documentData.create({
|
||||
data: {
|
||||
type: document.documentData.type,
|
||||
data: document.documentData.initialData,
|
||||
initialData: document.documentData.initialData,
|
||||
},
|
||||
});
|
||||
|
||||
let documentMeta: Prisma.DocumentCreateArgs['data']['documentMeta'] | undefined = undefined;
|
||||
|
||||
if (document.documentMeta) {
|
||||
documentMeta = {
|
||||
create: {
|
||||
...omit(document.documentMeta, ['id', 'documentId']),
|
||||
emailSettings: document.documentMeta.emailSettings || undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const createdDocument = await prisma.document.create({
|
||||
data: {
|
||||
userId: document.userId,
|
||||
teamId: teamId,
|
||||
title: document.title,
|
||||
documentDataId: documentData.id,
|
||||
authOptions: document.authOptions || undefined,
|
||||
visibility: document.visibility,
|
||||
qrToken: prefixedId('qr'),
|
||||
documentMeta,
|
||||
source: DocumentSource.DOCUMENT,
|
||||
},
|
||||
include: {
|
||||
recipients: true,
|
||||
documentMeta: true,
|
||||
},
|
||||
});
|
||||
|
||||
const recipientsToCreate = document.recipients.map((recipient) => ({
|
||||
documentId: createdDocument.id,
|
||||
email: recipient.email,
|
||||
name: recipient.name,
|
||||
role: recipient.role,
|
||||
signingOrder: recipient.signingOrder,
|
||||
token: nanoid(),
|
||||
fields: {
|
||||
createMany: {
|
||||
data: recipient.fields.map((field) => ({
|
||||
documentId: createdDocument.id,
|
||||
type: field.type,
|
||||
page: field.page,
|
||||
positionX: field.positionX,
|
||||
positionY: field.positionY,
|
||||
width: field.width,
|
||||
height: field.height,
|
||||
customText: '',
|
||||
inserted: false,
|
||||
fieldMeta: field.fieldMeta as PrismaJson.FieldMeta,
|
||||
})),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const recipients: Recipient[] = [];
|
||||
|
||||
for (const recipientData of recipientsToCreate) {
|
||||
const newRecipient = await prisma.recipient.create({
|
||||
data: recipientData,
|
||||
});
|
||||
|
||||
recipients.push(newRecipient);
|
||||
}
|
||||
|
||||
await triggerWebhook({
|
||||
event: WebhookTriggerEvents.DOCUMENT_CREATED,
|
||||
data: ZWebhookDocumentSchema.parse({
|
||||
...mapDocumentToWebhookDocumentPayload(createdDocument),
|
||||
recipients,
|
||||
documentMeta: createdDocument.documentMeta,
|
||||
}),
|
||||
userId: userId,
|
||||
teamId: teamId,
|
||||
});
|
||||
|
||||
return {
|
||||
documentId: createdDocument.id,
|
||||
};
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { DocumentAuditLog, Prisma } from '@prisma/client';
|
||||
import { type DocumentAuditLog, EnvelopeType, type Prisma } from '@prisma/client';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
@@ -6,7 +6,7 @@ import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
|
||||
import type { FindResultResponse } from '../../types/search-params';
|
||||
import { parseDocumentAuditLogData } from '../../utils/document-audit-logs';
|
||||
import { getDocumentWhereInput } from './get-document-by-id';
|
||||
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
|
||||
|
||||
export interface FindDocumentAuditLogsOptions {
|
||||
userId: number;
|
||||
@@ -35,22 +35,26 @@ export const findDocumentAuditLogs = async ({
|
||||
const orderByColumn = orderBy?.column ?? 'createdAt';
|
||||
const orderByDirection = orderBy?.direction ?? 'desc';
|
||||
|
||||
const { documentWhereInput } = await getDocumentWhereInput({
|
||||
documentId,
|
||||
const { envelopeWhereInput } = await getEnvelopeWhereInput({
|
||||
id: {
|
||||
type: 'documentId',
|
||||
id: documentId,
|
||||
},
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
userId,
|
||||
teamId,
|
||||
});
|
||||
|
||||
const document = await prisma.document.findFirst({
|
||||
where: documentWhereInput,
|
||||
const envelope = await prisma.envelope.findUnique({
|
||||
where: envelopeWhereInput,
|
||||
});
|
||||
|
||||
if (!document) {
|
||||
if (!envelope) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND);
|
||||
}
|
||||
|
||||
const whereClause: Prisma.DocumentAuditLogWhereInput = {
|
||||
documentId,
|
||||
envelopeId: envelope.id,
|
||||
};
|
||||
|
||||
// Filter events down to what we consider recent activity.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Document, DocumentSource, Prisma, Team, TeamEmail, User } from '@prisma/client';
|
||||
import { RecipientRole, SigningStatus, TeamMemberRole } from '@prisma/client';
|
||||
import type { DocumentSource, Envelope, Prisma, Team, TeamEmail, User } from '@prisma/client';
|
||||
import { EnvelopeType, RecipientRole, SigningStatus, TeamMemberRole } from '@prisma/client';
|
||||
import { DateTime } from 'luxon';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
@@ -22,7 +22,7 @@ export type FindDocumentsOptions = {
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
orderBy?: {
|
||||
column: keyof Omit<Document, 'document'>;
|
||||
column: keyof Pick<Envelope, 'createdAt'>;
|
||||
direction: 'asc' | 'desc';
|
||||
};
|
||||
period?: PeriodSelectorValue;
|
||||
@@ -69,7 +69,7 @@ export const findDocuments = async ({
|
||||
const orderByDirection = orderBy?.direction ?? 'desc';
|
||||
const teamMemberRole = team?.currentTeamRole ?? null;
|
||||
|
||||
const searchFilter: Prisma.DocumentWhereInput = {
|
||||
const searchFilter: Prisma.EnvelopeWhereInput = {
|
||||
OR: [
|
||||
{ title: { contains: query, mode: 'insensitive' } },
|
||||
{ externalId: { contains: query, mode: 'insensitive' } },
|
||||
@@ -111,7 +111,7 @@ export const findDocuments = async ({
|
||||
},
|
||||
];
|
||||
|
||||
let filters: Prisma.DocumentWhereInput | null = findDocumentsFilter(status, user, folderId);
|
||||
let filters: Prisma.EnvelopeWhereInput | null = findDocumentsFilter(status, user, folderId);
|
||||
|
||||
if (team) {
|
||||
filters = findTeamDocumentsFilter(status, team, visibilityFilters, folderId);
|
||||
@@ -127,7 +127,7 @@ export const findDocuments = async ({
|
||||
};
|
||||
}
|
||||
|
||||
let deletedFilter: Prisma.DocumentWhereInput = {
|
||||
let deletedFilter: Prisma.EnvelopeWhereInput = {
|
||||
AND: {
|
||||
OR: [
|
||||
{
|
||||
@@ -180,7 +180,7 @@ export const findDocuments = async ({
|
||||
};
|
||||
}
|
||||
|
||||
const whereAndClause: Prisma.DocumentWhereInput['AND'] = [
|
||||
const whereAndClause: Prisma.EnvelopeWhereInput['AND'] = [
|
||||
{ ...filters },
|
||||
{ ...deletedFilter },
|
||||
{ ...searchFilter },
|
||||
@@ -198,7 +198,8 @@ export const findDocuments = async ({
|
||||
});
|
||||
}
|
||||
|
||||
const whereClause: Prisma.DocumentWhereInput = {
|
||||
const whereClause: Prisma.EnvelopeWhereInput = {
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
AND: whereAndClause,
|
||||
};
|
||||
|
||||
@@ -225,7 +226,7 @@ export const findDocuments = async ({
|
||||
}
|
||||
|
||||
const [data, count] = await Promise.all([
|
||||
prisma.document.findMany({
|
||||
prisma.envelope.findMany({
|
||||
where: whereClause,
|
||||
skip: Math.max(page - 1, 0) * perPage,
|
||||
take: perPage,
|
||||
@@ -249,7 +250,7 @@ export const findDocuments = async ({
|
||||
},
|
||||
},
|
||||
}),
|
||||
prisma.document.count({
|
||||
prisma.envelope.count({
|
||||
where: whereClause,
|
||||
}),
|
||||
]);
|
||||
@@ -275,7 +276,7 @@ const findDocumentsFilter = (
|
||||
user: Pick<User, 'id' | 'email' | 'name'>,
|
||||
folderId?: string | null,
|
||||
) => {
|
||||
return match<ExtendedDocumentStatus, Prisma.DocumentWhereInput>(status)
|
||||
return match<ExtendedDocumentStatus, Prisma.EnvelopeWhereInput>(status)
|
||||
.with(ExtendedDocumentStatus.ALL, () => ({
|
||||
OR: [
|
||||
{
|
||||
@@ -414,14 +415,14 @@ const findDocumentsFilter = (
|
||||
const findTeamDocumentsFilter = (
|
||||
status: ExtendedDocumentStatus,
|
||||
team: Team & { teamEmail: TeamEmail | null },
|
||||
visibilityFilters: Prisma.DocumentWhereInput[],
|
||||
visibilityFilters: Prisma.EnvelopeWhereInput[],
|
||||
folderId?: string,
|
||||
) => {
|
||||
const teamEmail = team.teamEmail?.email ?? null;
|
||||
|
||||
return match<ExtendedDocumentStatus, Prisma.DocumentWhereInput | null>(status)
|
||||
return match<ExtendedDocumentStatus, Prisma.EnvelopeWhereInput | null>(status)
|
||||
.with(ExtendedDocumentStatus.ALL, () => {
|
||||
const filter: Prisma.DocumentWhereInput = {
|
||||
const filter: Prisma.EnvelopeWhereInput = {
|
||||
// Filter to display all documents that belong to the team.
|
||||
OR: [
|
||||
{
|
||||
@@ -483,7 +484,7 @@ const findTeamDocumentsFilter = (
|
||||
};
|
||||
})
|
||||
.with(ExtendedDocumentStatus.DRAFT, () => {
|
||||
const filter: Prisma.DocumentWhereInput = {
|
||||
const filter: Prisma.EnvelopeWhereInput = {
|
||||
OR: [
|
||||
{
|
||||
teamId: team.id,
|
||||
@@ -508,7 +509,7 @@ const findTeamDocumentsFilter = (
|
||||
return filter;
|
||||
})
|
||||
.with(ExtendedDocumentStatus.PENDING, () => {
|
||||
const filter: Prisma.DocumentWhereInput = {
|
||||
const filter: Prisma.EnvelopeWhereInput = {
|
||||
OR: [
|
||||
{
|
||||
teamId: team.id,
|
||||
@@ -550,7 +551,7 @@ const findTeamDocumentsFilter = (
|
||||
return filter;
|
||||
})
|
||||
.with(ExtendedDocumentStatus.COMPLETED, () => {
|
||||
const filter: Prisma.DocumentWhereInput = {
|
||||
const filter: Prisma.EnvelopeWhereInput = {
|
||||
status: ExtendedDocumentStatus.COMPLETED,
|
||||
OR: [
|
||||
{
|
||||
@@ -582,7 +583,7 @@ const findTeamDocumentsFilter = (
|
||||
return filter;
|
||||
})
|
||||
.with(ExtendedDocumentStatus.REJECTED, () => {
|
||||
const filter: Prisma.DocumentWhereInput = {
|
||||
const filter: Prisma.EnvelopeWhereInput = {
|
||||
status: ExtendedDocumentStatus.REJECTED,
|
||||
OR: [
|
||||
{
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { DocumentStatus, EnvelopeType } from '@prisma/client';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { mapSecondaryIdToDocumentId } from '../../utils/envelope';
|
||||
|
||||
export type GetDocumentByAccessTokenOptions = {
|
||||
token: string;
|
||||
};
|
||||
@@ -9,30 +13,62 @@ export const getDocumentByAccessToken = async ({ token }: GetDocumentByAccessTok
|
||||
throw new Error('Missing token');
|
||||
}
|
||||
|
||||
const result = await prisma.document.findFirstOrThrow({
|
||||
const result = await prisma.envelope.findFirstOrThrow({
|
||||
where: {
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
status: DocumentStatus.COMPLETED,
|
||||
qrToken: token,
|
||||
},
|
||||
// Do not provide extra information that is not needed.
|
||||
select: {
|
||||
id: true,
|
||||
secondaryId: true,
|
||||
internalVersion: true,
|
||||
title: true,
|
||||
completedAt: true,
|
||||
documentData: {
|
||||
team: {
|
||||
select: {
|
||||
url: true,
|
||||
},
|
||||
},
|
||||
envelopeItems: {
|
||||
select: {
|
||||
id: true,
|
||||
type: true,
|
||||
data: true,
|
||||
initialData: true,
|
||||
title: true,
|
||||
order: true,
|
||||
documentDataId: true,
|
||||
envelopeId: true,
|
||||
documentData: {
|
||||
select: {
|
||||
id: true,
|
||||
type: true,
|
||||
data: true,
|
||||
initialData: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
documentMeta: {
|
||||
_count: {
|
||||
select: {
|
||||
password: true,
|
||||
recipients: true,
|
||||
},
|
||||
},
|
||||
recipients: true,
|
||||
},
|
||||
});
|
||||
|
||||
return result;
|
||||
const firstDocumentData = result.envelopeItems[0].documentData;
|
||||
|
||||
if (!firstDocumentData) {
|
||||
throw new Error('Missing document data');
|
||||
}
|
||||
|
||||
return {
|
||||
id: mapSecondaryIdToDocumentId(result.secondaryId),
|
||||
internalVersion: result.internalVersion,
|
||||
title: result.title,
|
||||
completedAt: result.completedAt,
|
||||
envelopeItems: result.envelopeItems,
|
||||
recipientCount: result._count.recipients,
|
||||
documentTeamUrl: result.team.url,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,156 +0,0 @@
|
||||
import type { Prisma } from '@prisma/client';
|
||||
import { DocumentStatus, TeamMemberRole } from '@prisma/client';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { DocumentVisibility } from '../../types/document-visibility';
|
||||
import { getTeamById } from '../team/get-team';
|
||||
|
||||
export type GetDocumentByIdOptions = {
|
||||
documentId: number;
|
||||
userId: number;
|
||||
teamId: number;
|
||||
folderId?: string;
|
||||
};
|
||||
|
||||
export const getDocumentById = async ({
|
||||
documentId,
|
||||
userId,
|
||||
teamId,
|
||||
folderId,
|
||||
}: GetDocumentByIdOptions) => {
|
||||
const { documentWhereInput } = await getDocumentWhereInput({
|
||||
documentId,
|
||||
userId,
|
||||
teamId,
|
||||
});
|
||||
|
||||
const document = await prisma.document.findFirst({
|
||||
where: {
|
||||
...documentWhereInput,
|
||||
folderId,
|
||||
},
|
||||
include: {
|
||||
documentData: true,
|
||||
documentMeta: true,
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
recipients: {
|
||||
select: {
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
team: {
|
||||
select: {
|
||||
id: true,
|
||||
url: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!document) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Document could not be found',
|
||||
});
|
||||
}
|
||||
|
||||
return document;
|
||||
};
|
||||
|
||||
export type GetDocumentWhereInputOptions = {
|
||||
documentId: number;
|
||||
userId: number;
|
||||
teamId: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate the where input for a given Prisma document query.
|
||||
*
|
||||
* This will return a query that allows a user to get a document if they have valid access to it.
|
||||
*/
|
||||
export const getDocumentWhereInput = async ({
|
||||
documentId,
|
||||
userId,
|
||||
teamId,
|
||||
}: GetDocumentWhereInputOptions) => {
|
||||
const team = await getTeamById({ teamId, userId });
|
||||
|
||||
const user = await prisma.user.findFirstOrThrow({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
});
|
||||
|
||||
const teamVisibilityFilters = match(team.currentTeamRole)
|
||||
.with(TeamMemberRole.ADMIN, () => [
|
||||
DocumentVisibility.EVERYONE,
|
||||
DocumentVisibility.MANAGER_AND_ABOVE,
|
||||
DocumentVisibility.ADMIN,
|
||||
])
|
||||
.with(TeamMemberRole.MANAGER, () => [
|
||||
DocumentVisibility.EVERYONE,
|
||||
DocumentVisibility.MANAGER_AND_ABOVE,
|
||||
])
|
||||
.otherwise(() => [DocumentVisibility.EVERYONE]);
|
||||
|
||||
const documentOrInput: Prisma.DocumentWhereInput[] = [
|
||||
// Allow access if they own the document.
|
||||
{
|
||||
userId,
|
||||
},
|
||||
// Or, if they belong to the team that the document is associated with.
|
||||
{
|
||||
visibility: {
|
||||
in: teamVisibilityFilters,
|
||||
},
|
||||
teamId: team.id,
|
||||
},
|
||||
// Or, if they are a recipient of the document.
|
||||
{
|
||||
status: {
|
||||
not: DocumentStatus.DRAFT,
|
||||
},
|
||||
recipients: {
|
||||
some: {
|
||||
email: user.email,
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// Allow access to documents sent to or from the team email.
|
||||
if (team.teamEmail) {
|
||||
documentOrInput.push(
|
||||
{
|
||||
recipients: {
|
||||
some: {
|
||||
email: team.teamEmail.email,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
user: {
|
||||
email: team.teamEmail.email,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const documentWhereInput: Prisma.DocumentWhereUniqueInput = {
|
||||
id: documentId,
|
||||
OR: documentOrInput,
|
||||
};
|
||||
|
||||
return {
|
||||
documentWhereInput,
|
||||
team,
|
||||
};
|
||||
};
|
||||
@@ -1,8 +1,10 @@
|
||||
import { EnvelopeType } from '@prisma/client';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import type { DocumentWithRecipient } from '@documenso/prisma/types/document-with-recipient';
|
||||
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import type { TDocumentAuthMethods } from '../../types/document-auth';
|
||||
import { mapSecondaryIdToDocumentId } from '../../utils/envelope';
|
||||
import { isRecipientAuthorized } from './is-recipient-authorized';
|
||||
|
||||
export interface GetDocumentAndSenderByTokenOptions {
|
||||
@@ -39,8 +41,9 @@ export const getDocumentByToken = async ({ token }: GetDocumentByTokenOptions) =
|
||||
throw new Error('Missing token');
|
||||
}
|
||||
|
||||
const result = await prisma.document.findFirstOrThrow({
|
||||
const result = await prisma.envelope.findFirstOrThrow({
|
||||
where: {
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
recipients: {
|
||||
some: {
|
||||
token,
|
||||
@@ -64,8 +67,9 @@ export const getDocumentAndSenderByToken = async ({
|
||||
throw new Error('Missing token');
|
||||
}
|
||||
|
||||
const result = await prisma.document.findFirstOrThrow({
|
||||
const result = await prisma.envelope.findFirstOrThrow({
|
||||
where: {
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
recipients: {
|
||||
some: {
|
||||
token,
|
||||
@@ -80,13 +84,17 @@ export const getDocumentAndSenderByToken = async ({
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
documentData: true,
|
||||
documentMeta: true,
|
||||
recipients: {
|
||||
where: {
|
||||
token,
|
||||
},
|
||||
},
|
||||
envelopeItems: {
|
||||
select: {
|
||||
documentData: true,
|
||||
},
|
||||
},
|
||||
team: {
|
||||
select: {
|
||||
name: true,
|
||||
@@ -102,6 +110,12 @@ export const getDocumentAndSenderByToken = async ({
|
||||
},
|
||||
});
|
||||
|
||||
const firstDocumentData = result.envelopeItems[0].documentData;
|
||||
|
||||
if (!firstDocumentData) {
|
||||
throw new Error('Missing document data');
|
||||
}
|
||||
|
||||
const recipient = result.recipients[0];
|
||||
|
||||
// Sanity check, should not be possible.
|
||||
@@ -127,6 +141,8 @@ export const getDocumentAndSenderByToken = async ({
|
||||
});
|
||||
}
|
||||
|
||||
const legacyDocumentId = mapSecondaryIdToDocumentId(result.secondaryId);
|
||||
|
||||
return {
|
||||
...result,
|
||||
user: {
|
||||
@@ -134,64 +150,8 @@ export const getDocumentAndSenderByToken = async ({
|
||||
email: result.user.email,
|
||||
name: result.user.name,
|
||||
},
|
||||
documentData: firstDocumentData,
|
||||
id: legacyDocumentId,
|
||||
envelopeId: result.id,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Get a Document and a Recipient by the recipient token.
|
||||
*/
|
||||
export const getDocumentAndRecipientByToken = async ({
|
||||
token,
|
||||
userId,
|
||||
accessAuth,
|
||||
requireAccessAuth = true,
|
||||
}: GetDocumentAndRecipientByTokenOptions): Promise<DocumentWithRecipient> => {
|
||||
if (!token) {
|
||||
throw new Error('Missing token');
|
||||
}
|
||||
|
||||
const result = await prisma.document.findFirstOrThrow({
|
||||
where: {
|
||||
recipients: {
|
||||
some: {
|
||||
token,
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
recipients: {
|
||||
where: {
|
||||
token,
|
||||
},
|
||||
},
|
||||
documentData: true,
|
||||
},
|
||||
});
|
||||
|
||||
const [recipient] = result.recipients;
|
||||
|
||||
// Sanity check, should not be possible.
|
||||
if (!recipient) {
|
||||
throw new Error('Missing recipient');
|
||||
}
|
||||
|
||||
let documentAccessValid = true;
|
||||
|
||||
if (requireAccessAuth) {
|
||||
documentAccessValid = await isRecipientAuthorized({
|
||||
type: 'ACCESS',
|
||||
documentAuthOptions: result.authOptions,
|
||||
recipient,
|
||||
userId,
|
||||
authOptions: accessAuth,
|
||||
});
|
||||
}
|
||||
|
||||
if (!documentAccessValid) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'Invalid access values',
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
@@ -4,15 +4,15 @@ import { DOCUMENT_AUDIT_LOG_TYPE, DOCUMENT_EMAIL_TYPE } from '../../types/docume
|
||||
import { parseDocumentAuditLogData } from '../../utils/document-audit-logs';
|
||||
|
||||
export type GetDocumentCertificateAuditLogsOptions = {
|
||||
id: number;
|
||||
envelopeId: string;
|
||||
};
|
||||
|
||||
export const getDocumentCertificateAuditLogs = async ({
|
||||
id,
|
||||
envelopeId,
|
||||
}: GetDocumentCertificateAuditLogsOptions) => {
|
||||
const rawAuditLogs = await prisma.documentAuditLog.findMany({
|
||||
where: {
|
||||
documentId: id,
|
||||
envelopeId,
|
||||
type: {
|
||||
in: [
|
||||
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED,
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
export interface GetDocumentMetaByDocumentIdOptions {
|
||||
id: number;
|
||||
}
|
||||
|
||||
export const getDocumentMetaByDocumentId = async ({ id }: GetDocumentMetaByDocumentIdOptions) => {
|
||||
return await prisma.documentMeta.findFirstOrThrow({
|
||||
where: {
|
||||
documentId: id,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -1,67 +1,66 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { EnvelopeType } from '@prisma/client';
|
||||
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { getDocumentWhereInput } from './get-document-by-id';
|
||||
import { type EnvelopeIdOptions, mapSecondaryIdToDocumentId } from '../../utils/envelope';
|
||||
import { getEnvelopeById } from '../envelope/get-envelope-by-id';
|
||||
|
||||
export type GetDocumentWithDetailsByIdOptions = {
|
||||
documentId: number;
|
||||
id: EnvelopeIdOptions;
|
||||
userId: number;
|
||||
teamId: number;
|
||||
};
|
||||
|
||||
export const getDocumentWithDetailsById = async ({
|
||||
documentId,
|
||||
id,
|
||||
userId,
|
||||
teamId,
|
||||
}: GetDocumentWithDetailsByIdOptions) => {
|
||||
const { documentWhereInput } = await getDocumentWhereInput({
|
||||
documentId,
|
||||
const envelope = await getEnvelopeById({
|
||||
id,
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
userId,
|
||||
teamId,
|
||||
});
|
||||
|
||||
const document = await prisma.document.findFirst({
|
||||
where: {
|
||||
...documentWhereInput,
|
||||
},
|
||||
include: {
|
||||
documentData: true,
|
||||
documentMeta: true,
|
||||
recipients: true,
|
||||
folder: true,
|
||||
fields: {
|
||||
include: {
|
||||
signature: true,
|
||||
recipient: {
|
||||
select: {
|
||||
name: true,
|
||||
email: true,
|
||||
signingStatus: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
team: {
|
||||
select: {
|
||||
id: true,
|
||||
url: true,
|
||||
},
|
||||
},
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const legacyDocumentId = mapSecondaryIdToDocumentId(envelope.secondaryId);
|
||||
|
||||
if (!document) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Document not found',
|
||||
});
|
||||
const firstDocumentData = envelope.envelopeItems[0].documentData;
|
||||
|
||||
if (!firstDocumentData) {
|
||||
throw new Error('Document data not found');
|
||||
}
|
||||
|
||||
return document;
|
||||
return {
|
||||
...envelope,
|
||||
envelopeId: envelope.id,
|
||||
documentData: {
|
||||
...firstDocumentData,
|
||||
envelopeItemId: envelope.envelopeItems[0].id,
|
||||
},
|
||||
id: legacyDocumentId,
|
||||
fields: envelope.fields.map((field) => ({
|
||||
...field,
|
||||
documentId: legacyDocumentId,
|
||||
templateId: null,
|
||||
})),
|
||||
user: {
|
||||
id: envelope.userId,
|
||||
name: envelope.user.name,
|
||||
email: envelope.user.email,
|
||||
},
|
||||
team: {
|
||||
id: envelope.teamId,
|
||||
url: envelope.team.url,
|
||||
},
|
||||
recipients: envelope.recipients.map((recipient) => ({
|
||||
...recipient,
|
||||
documentId: legacyDocumentId,
|
||||
templateId: null,
|
||||
})),
|
||||
documentDataId: firstDocumentData.id,
|
||||
documentMeta: {
|
||||
...envelope.documentMeta,
|
||||
documentId: legacyDocumentId,
|
||||
password: null,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -7,7 +7,7 @@ export type GetRecipientOrSenderByShareLinkSlugOptions = {
|
||||
export const getRecipientOrSenderByShareLinkSlug = async ({
|
||||
slug,
|
||||
}: GetRecipientOrSenderByShareLinkSlugOptions) => {
|
||||
const { documentId, email } = await prisma.documentShareLink.findFirstOrThrow({
|
||||
const { envelopeId, email } = await prisma.documentShareLink.findFirstOrThrow({
|
||||
where: {
|
||||
slug,
|
||||
},
|
||||
@@ -15,7 +15,7 @@ export const getRecipientOrSenderByShareLinkSlug = async ({
|
||||
|
||||
const sender = await prisma.user.findFirst({
|
||||
where: {
|
||||
documents: { some: { id: documentId } },
|
||||
envelopes: { some: { id: envelopeId } },
|
||||
email,
|
||||
},
|
||||
select: {
|
||||
@@ -31,7 +31,7 @@ export const getRecipientOrSenderByShareLinkSlug = async ({
|
||||
|
||||
const recipient = await prisma.recipient.findFirst({
|
||||
where: {
|
||||
documentId,
|
||||
envelopeId,
|
||||
email,
|
||||
},
|
||||
select: {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { TeamMemberRole } from '@prisma/client';
|
||||
import { EnvelopeType, TeamMemberRole } from '@prisma/client';
|
||||
import type { Prisma, User } from '@prisma/client';
|
||||
import { SigningStatus } from '@prisma/client';
|
||||
import { DocumentVisibility } from '@prisma/client';
|
||||
@@ -25,7 +25,7 @@ export const getStats = async ({
|
||||
folderId,
|
||||
...options
|
||||
}: GetStatsInput) => {
|
||||
let createdAt: Prisma.DocumentWhereInput['createdAt'];
|
||||
let createdAt: Prisma.EnvelopeWhereInput['createdAt'];
|
||||
|
||||
if (period) {
|
||||
const daysAgo = parseInt(period.replace(/d$/, ''), 10);
|
||||
@@ -90,13 +90,13 @@ export const getStats = async ({
|
||||
|
||||
type GetCountsOption = {
|
||||
user: Pick<User, 'id' | 'email'>;
|
||||
createdAt: Prisma.DocumentWhereInput['createdAt'];
|
||||
createdAt: Prisma.EnvelopeWhereInput['createdAt'];
|
||||
search?: string;
|
||||
folderId?: string | null;
|
||||
};
|
||||
|
||||
const getCounts = async ({ user, createdAt, search, folderId }: GetCountsOption) => {
|
||||
const searchFilter: Prisma.DocumentWhereInput = {
|
||||
const searchFilter: Prisma.EnvelopeWhereInput = {
|
||||
OR: [
|
||||
{ title: { contains: search, mode: 'insensitive' } },
|
||||
{ recipients: { some: { name: { contains: search, mode: 'insensitive' } } } },
|
||||
@@ -108,12 +108,13 @@ const getCounts = async ({ user, createdAt, search, folderId }: GetCountsOption)
|
||||
|
||||
return Promise.all([
|
||||
// Owner counts.
|
||||
prisma.document.groupBy({
|
||||
prisma.envelope.groupBy({
|
||||
by: ['status'],
|
||||
_count: {
|
||||
_all: true,
|
||||
},
|
||||
where: {
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
userId: user.id,
|
||||
createdAt,
|
||||
deletedAt: null,
|
||||
@@ -121,12 +122,13 @@ const getCounts = async ({ user, createdAt, search, folderId }: GetCountsOption)
|
||||
},
|
||||
}),
|
||||
// Not signed counts.
|
||||
prisma.document.groupBy({
|
||||
prisma.envelope.groupBy({
|
||||
by: ['status'],
|
||||
_count: {
|
||||
_all: true,
|
||||
},
|
||||
where: {
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
status: ExtendedDocumentStatus.PENDING,
|
||||
recipients: {
|
||||
some: {
|
||||
@@ -140,12 +142,13 @@ const getCounts = async ({ user, createdAt, search, folderId }: GetCountsOption)
|
||||
},
|
||||
}),
|
||||
// Has signed counts.
|
||||
prisma.document.groupBy({
|
||||
prisma.envelope.groupBy({
|
||||
by: ['status'],
|
||||
_count: {
|
||||
_all: true,
|
||||
},
|
||||
where: {
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
createdAt,
|
||||
user: {
|
||||
email: {
|
||||
@@ -186,7 +189,7 @@ type GetTeamCountsOption = {
|
||||
senderIds?: number[];
|
||||
currentUserEmail: string;
|
||||
userId: number;
|
||||
createdAt: Prisma.DocumentWhereInput['createdAt'];
|
||||
createdAt: Prisma.EnvelopeWhereInput['createdAt'];
|
||||
currentTeamMemberRole?: TeamMemberRole;
|
||||
search?: string;
|
||||
folderId?: string | null;
|
||||
@@ -197,14 +200,14 @@ const getTeamCounts = async (options: GetTeamCountsOption) => {
|
||||
|
||||
const senderIds = options.senderIds ?? [];
|
||||
|
||||
const userIdWhereClause: Prisma.DocumentWhereInput['userId'] =
|
||||
const userIdWhereClause: Prisma.EnvelopeWhereInput['userId'] =
|
||||
senderIds.length > 0
|
||||
? {
|
||||
in: senderIds,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const searchFilter: Prisma.DocumentWhereInput = {
|
||||
const searchFilter: Prisma.EnvelopeWhereInput = {
|
||||
OR: [
|
||||
{ title: { contains: options.search, mode: 'insensitive' } },
|
||||
{ recipients: { some: { name: { contains: options.search, mode: 'insensitive' } } } },
|
||||
@@ -212,7 +215,8 @@ const getTeamCounts = async (options: GetTeamCountsOption) => {
|
||||
],
|
||||
};
|
||||
|
||||
let ownerCountsWhereInput: Prisma.DocumentWhereInput = {
|
||||
let ownerCountsWhereInput: Prisma.EnvelopeWhereInput = {
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
userId: userIdWhereClause,
|
||||
createdAt,
|
||||
teamId,
|
||||
@@ -223,7 +227,7 @@ const getTeamCounts = async (options: GetTeamCountsOption) => {
|
||||
let notSignedCountsGroupByArgs = null;
|
||||
let hasSignedCountsGroupByArgs = null;
|
||||
|
||||
const visibilityFiltersWhereInput: Prisma.DocumentWhereInput = {
|
||||
const visibilityFiltersWhereInput: Prisma.EnvelopeWhereInput = {
|
||||
AND: [
|
||||
{ deletedAt: null },
|
||||
{
|
||||
@@ -267,6 +271,7 @@ const getTeamCounts = async (options: GetTeamCountsOption) => {
|
||||
|
||||
if (teamEmail) {
|
||||
ownerCountsWhereInput = {
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
userId: userIdWhereClause,
|
||||
createdAt,
|
||||
OR: [
|
||||
@@ -288,6 +293,7 @@ const getTeamCounts = async (options: GetTeamCountsOption) => {
|
||||
_all: true,
|
||||
},
|
||||
where: {
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
userId: userIdWhereClause,
|
||||
createdAt,
|
||||
folderId,
|
||||
@@ -301,7 +307,7 @@ const getTeamCounts = async (options: GetTeamCountsOption) => {
|
||||
},
|
||||
deletedAt: null,
|
||||
},
|
||||
} satisfies Prisma.DocumentGroupByArgs;
|
||||
} satisfies Prisma.EnvelopeGroupByArgs;
|
||||
|
||||
hasSignedCountsGroupByArgs = {
|
||||
by: ['status'],
|
||||
@@ -309,6 +315,7 @@ const getTeamCounts = async (options: GetTeamCountsOption) => {
|
||||
_all: true,
|
||||
},
|
||||
where: {
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
userId: userIdWhereClause,
|
||||
createdAt,
|
||||
folderId,
|
||||
@@ -336,18 +343,18 @@ const getTeamCounts = async (options: GetTeamCountsOption) => {
|
||||
},
|
||||
],
|
||||
},
|
||||
} satisfies Prisma.DocumentGroupByArgs;
|
||||
} satisfies Prisma.EnvelopeGroupByArgs;
|
||||
}
|
||||
|
||||
return Promise.all([
|
||||
prisma.document.groupBy({
|
||||
prisma.envelope.groupBy({
|
||||
by: ['status'],
|
||||
_count: {
|
||||
_all: true,
|
||||
},
|
||||
where: ownerCountsWhereInput,
|
||||
}),
|
||||
notSignedCountsGroupByArgs ? prisma.document.groupBy(notSignedCountsGroupByArgs) : [],
|
||||
hasSignedCountsGroupByArgs ? prisma.document.groupBy(hasSignedCountsGroupByArgs) : [],
|
||||
notSignedCountsGroupByArgs ? prisma.envelope.groupBy(notSignedCountsGroupByArgs) : [],
|
||||
hasSignedCountsGroupByArgs ? prisma.envelope.groupBy(hasSignedCountsGroupByArgs) : [],
|
||||
]);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Document, Recipient } from '@prisma/client';
|
||||
import type { Envelope, Recipient } from '@prisma/client';
|
||||
import { verifyAuthenticationResponse } from '@simplewebauthn/server';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
@@ -17,8 +17,8 @@ import { extractDocumentAuthMethods } from '../../utils/document-auth';
|
||||
type IsRecipientAuthorizedOptions = {
|
||||
// !: Probably find a better name than 'ACCESS_2FA' if requirements change.
|
||||
type: 'ACCESS' | 'ACCESS_2FA' | 'ACTION';
|
||||
documentAuthOptions: Document['authOptions'];
|
||||
recipient: Pick<Recipient, 'authOptions' | 'email' | 'documentId'>;
|
||||
documentAuthOptions: Envelope['authOptions'];
|
||||
recipient: Pick<Recipient, 'authOptions' | 'email' | 'envelopeId'>;
|
||||
|
||||
/**
|
||||
* The ID of the user who initiated the request.
|
||||
@@ -125,14 +125,8 @@ export const isRecipientAuthorized = async ({
|
||||
}
|
||||
|
||||
if (type === 'ACCESS_2FA' && method === 'email') {
|
||||
if (!recipient.documentId) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Document ID is required for email 2FA verification',
|
||||
});
|
||||
}
|
||||
|
||||
return await validateTwoFactorTokenFromEmail({
|
||||
documentId: recipient.documentId,
|
||||
envelopeId: recipient.envelopeId,
|
||||
email: recipient.email,
|
||||
code: token,
|
||||
window: 10, // 5 minutes worth of tokens
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { SigningStatus } from '@prisma/client';
|
||||
import { EnvelopeType, SigningStatus } from '@prisma/client';
|
||||
|
||||
import { jobs } from '@documenso/lib/jobs/client';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
@@ -7,17 +7,19 @@ import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
|
||||
import type { RequestMetadata } from '../../universal/extract-request-metadata';
|
||||
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
|
||||
import type { EnvelopeIdOptions } from '../../utils/envelope';
|
||||
import { mapSecondaryIdToDocumentId, unsafeBuildEnvelopeIdQuery } from '../../utils/envelope';
|
||||
|
||||
export type RejectDocumentWithTokenOptions = {
|
||||
token: string;
|
||||
documentId: number;
|
||||
id: EnvelopeIdOptions;
|
||||
reason: string;
|
||||
requestMetadata?: RequestMetadata;
|
||||
};
|
||||
|
||||
export async function rejectDocumentWithToken({
|
||||
token,
|
||||
documentId,
|
||||
id,
|
||||
reason,
|
||||
requestMetadata,
|
||||
}: RejectDocumentWithTokenOptions) {
|
||||
@@ -25,16 +27,16 @@ export async function rejectDocumentWithToken({
|
||||
const recipient = await prisma.recipient.findFirst({
|
||||
where: {
|
||||
token,
|
||||
documentId,
|
||||
envelope: unsafeBuildEnvelopeIdQuery(id, EnvelopeType.DOCUMENT),
|
||||
},
|
||||
include: {
|
||||
document: true,
|
||||
envelope: true,
|
||||
},
|
||||
});
|
||||
|
||||
const document = recipient?.document;
|
||||
const envelope = recipient?.envelope;
|
||||
|
||||
if (!recipient || !document) {
|
||||
if (!recipient || !envelope) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Document or recipient not found',
|
||||
});
|
||||
@@ -54,7 +56,7 @@ export async function rejectDocumentWithToken({
|
||||
}),
|
||||
prisma.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
documentId,
|
||||
envelopeId: envelope.id,
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_REJECTED,
|
||||
user: {
|
||||
name: recipient.name,
|
||||
@@ -72,11 +74,13 @@ export async function rejectDocumentWithToken({
|
||||
}),
|
||||
]);
|
||||
|
||||
const legacyDocumentId = mapSecondaryIdToDocumentId(envelope.secondaryId);
|
||||
|
||||
// Trigger the seal document job to process the document asynchronously
|
||||
await jobs.triggerJob({
|
||||
name: 'internal.seal-document',
|
||||
payload: {
|
||||
documentId,
|
||||
documentId: legacyDocumentId,
|
||||
requestMetadata,
|
||||
},
|
||||
});
|
||||
@@ -86,7 +90,7 @@ export async function rejectDocumentWithToken({
|
||||
name: 'send.signing.rejected.emails',
|
||||
payload: {
|
||||
recipientId: recipient.id,
|
||||
documentId,
|
||||
documentId: legacyDocumentId,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -94,7 +98,7 @@ export async function rejectDocumentWithToken({
|
||||
await jobs.triggerJob({
|
||||
name: 'send.document.cancelled.emails',
|
||||
payload: {
|
||||
documentId,
|
||||
documentId: legacyDocumentId,
|
||||
cancellationReason: reason,
|
||||
requestMetadata,
|
||||
},
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { createElement } from 'react';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { DocumentStatus, OrganisationType, RecipientRole, SigningStatus } from '@prisma/client';
|
||||
import {
|
||||
DocumentStatus,
|
||||
EnvelopeType,
|
||||
OrganisationType,
|
||||
RecipientRole,
|
||||
SigningStatus,
|
||||
} from '@prisma/client';
|
||||
|
||||
import { mailer } from '@documenso/email/mailer';
|
||||
import { DocumentInviteEmailTemplate } from '@documenso/email/templates/document-invite';
|
||||
@@ -19,12 +25,13 @@ import { getI18nInstance } from '../../client-only/providers/i18n-server';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
|
||||
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
|
||||
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 { getDocumentWhereInput } from './get-document-by-id';
|
||||
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
|
||||
|
||||
export type ResendDocumentOptions = {
|
||||
documentId: number;
|
||||
id: EnvelopeIdOptions;
|
||||
userId: number;
|
||||
recipients: number[];
|
||||
teamId: number;
|
||||
@@ -32,7 +39,7 @@ export type ResendDocumentOptions = {
|
||||
};
|
||||
|
||||
export const resendDocument = async ({
|
||||
documentId,
|
||||
id,
|
||||
userId,
|
||||
recipients,
|
||||
teamId,
|
||||
@@ -42,16 +49,22 @@ export const resendDocument = async ({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
},
|
||||
});
|
||||
|
||||
const { documentWhereInput } = await getDocumentWhereInput({
|
||||
documentId,
|
||||
const { envelopeWhereInput } = await getEnvelopeWhereInput({
|
||||
id,
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
userId,
|
||||
teamId,
|
||||
});
|
||||
|
||||
const document = await prisma.document.findUnique({
|
||||
where: documentWhereInput,
|
||||
const envelope = await prisma.envelope.findUnique({
|
||||
where: envelopeWhereInput,
|
||||
include: {
|
||||
recipients: true,
|
||||
documentMeta: true,
|
||||
@@ -64,31 +77,29 @@ export const resendDocument = async ({
|
||||
},
|
||||
});
|
||||
|
||||
const customEmail = document?.documentMeta;
|
||||
|
||||
if (!document) {
|
||||
if (!envelope) {
|
||||
throw new Error('Document not found');
|
||||
}
|
||||
|
||||
if (document.recipients.length === 0) {
|
||||
if (envelope.recipients.length === 0) {
|
||||
throw new Error('Document has no recipients');
|
||||
}
|
||||
|
||||
if (document.status === DocumentStatus.DRAFT) {
|
||||
if (envelope.status === DocumentStatus.DRAFT) {
|
||||
throw new Error('Can not send draft document');
|
||||
}
|
||||
|
||||
if (isDocumentCompleted(document.status)) {
|
||||
if (isDocumentCompleted(envelope.status)) {
|
||||
throw new Error('Can not send completed document');
|
||||
}
|
||||
|
||||
const recipientsToRemind = document.recipients.filter(
|
||||
const recipientsToRemind = envelope.recipients.filter(
|
||||
(recipient) =>
|
||||
recipients.includes(recipient.id) && recipient.signingStatus === SigningStatus.NOT_SIGNED,
|
||||
);
|
||||
|
||||
const isRecipientSigningRequestEmailEnabled = extractDerivedDocumentEmailSettings(
|
||||
document.documentMeta,
|
||||
envelope.documentMeta,
|
||||
).recipientSigningRequest;
|
||||
|
||||
if (!isRecipientSigningRequestEmailEnabled) {
|
||||
@@ -100,9 +111,9 @@ export const resendDocument = async ({
|
||||
emailType: 'RECIPIENT',
|
||||
source: {
|
||||
type: 'team',
|
||||
teamId: document.teamId,
|
||||
teamId: envelope.teamId,
|
||||
},
|
||||
meta: document.documentMeta,
|
||||
meta: envelope.documentMeta,
|
||||
});
|
||||
|
||||
await Promise.all(
|
||||
@@ -122,42 +133,42 @@ export const resendDocument = async ({
|
||||
._(RECIPIENT_ROLES_DESCRIPTION[recipient.role].actionVerb)
|
||||
.toLowerCase();
|
||||
|
||||
let emailMessage = customEmail?.message || '';
|
||||
let emailMessage = envelope.documentMeta.message || '';
|
||||
let emailSubject = i18n._(msg`Reminder: Please ${recipientActionVerb} this document`);
|
||||
|
||||
if (selfSigner) {
|
||||
emailMessage = i18n._(
|
||||
msg`You have initiated the document ${`"${document.title}"`} that requires you to ${recipientActionVerb} it.`,
|
||||
msg`You have initiated the document ${`"${envelope.title}"`} that requires you to ${recipientActionVerb} it.`,
|
||||
);
|
||||
emailSubject = i18n._(msg`Reminder: Please ${recipientActionVerb} your document`);
|
||||
}
|
||||
|
||||
if (organisationType === OrganisationType.ORGANISATION) {
|
||||
emailSubject = i18n._(
|
||||
msg`Reminder: ${document.team.name} invited you to ${recipientActionVerb} a document`,
|
||||
msg`Reminder: ${envelope.team.name} invited you to ${recipientActionVerb} a document`,
|
||||
);
|
||||
emailMessage =
|
||||
customEmail?.message ||
|
||||
envelope.documentMeta.message ||
|
||||
i18n._(
|
||||
msg`${user.name || user.email} on behalf of "${document.team.name}" has invited you to ${recipientActionVerb} the document "${document.title}".`,
|
||||
msg`${user.name || user.email} on behalf of "${envelope.team.name}" has invited you to ${recipientActionVerb} the document "${envelope.title}".`,
|
||||
);
|
||||
}
|
||||
|
||||
const customEmailTemplate = {
|
||||
'signer.name': name,
|
||||
'signer.email': email,
|
||||
'document.name': document.title,
|
||||
'document.name': envelope.title,
|
||||
};
|
||||
|
||||
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
|
||||
const signDocumentLink = `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`;
|
||||
|
||||
const template = createElement(DocumentInviteEmailTemplate, {
|
||||
documentName: document.title,
|
||||
documentName: envelope.title,
|
||||
inviterName: user.name || undefined,
|
||||
inviterEmail:
|
||||
organisationType === OrganisationType.ORGANISATION
|
||||
? document.team?.teamEmail?.email || user.email
|
||||
? envelope.team?.teamEmail?.email || user.email
|
||||
: user.email,
|
||||
assetBaseUrl,
|
||||
signDocumentLink,
|
||||
@@ -165,7 +176,7 @@ export const resendDocument = async ({
|
||||
role: recipient.role,
|
||||
selfSigner,
|
||||
organisationType,
|
||||
teamName: document.team?.name,
|
||||
teamName: envelope.team?.name,
|
||||
});
|
||||
|
||||
const [html, text] = await Promise.all([
|
||||
@@ -189,9 +200,9 @@ export const resendDocument = async ({
|
||||
},
|
||||
from: senderEmail,
|
||||
replyTo: replyToEmail,
|
||||
subject: customEmail?.subject
|
||||
subject: envelope.documentMeta.subject
|
||||
? renderCustomEmailTemplate(
|
||||
i18n._(msg`Reminder: ${customEmail.subject}`),
|
||||
i18n._(msg`Reminder: ${envelope.documentMeta.subject}`),
|
||||
customEmailTemplate,
|
||||
)
|
||||
: emailSubject,
|
||||
@@ -202,7 +213,7 @@ export const resendDocument = async ({
|
||||
await tx.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
|
||||
documentId: document.id,
|
||||
envelopeId: envelope.id,
|
||||
metadata: requestMetadata,
|
||||
data: {
|
||||
emailType: recipientEmailType,
|
||||
|
||||
@@ -1,267 +0,0 @@
|
||||
import { DocumentStatus, RecipientRole, SigningStatus, WebhookTriggerEvents } from '@prisma/client';
|
||||
import { nanoid } from 'nanoid';
|
||||
import path from 'node:path';
|
||||
import { PDFDocument } from 'pdf-lib';
|
||||
|
||||
import PostHogServerClient from '@documenso/lib/server-only/feature-flags/get-post-hog-server-client';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
||||
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { signPdf } from '@documenso/signing';
|
||||
|
||||
import {
|
||||
ZWebhookDocumentSchema,
|
||||
mapDocumentToWebhookDocumentPayload,
|
||||
} from '../../types/webhook-payload';
|
||||
import type { RequestMetadata } from '../../universal/extract-request-metadata';
|
||||
import { getFileServerSide } from '../../universal/upload/get-file.server';
|
||||
import { putPdfFileServerSide } from '../../universal/upload/put-file.server';
|
||||
import { fieldsContainUnsignedRequiredField } from '../../utils/advanced-fields-helpers';
|
||||
import { getAuditLogsPdf } from '../htmltopdf/get-audit-logs-pdf';
|
||||
import { getCertificatePdf } from '../htmltopdf/get-certificate-pdf';
|
||||
import { addRejectionStampToPdf } from '../pdf/add-rejection-stamp-to-pdf';
|
||||
import { flattenAnnotations } from '../pdf/flatten-annotations';
|
||||
import { flattenForm } from '../pdf/flatten-form';
|
||||
import { insertFieldInPDF } from '../pdf/insert-field-in-pdf';
|
||||
import { legacy_insertFieldInPDF } from '../pdf/legacy-insert-field-in-pdf';
|
||||
import { normalizeSignatureAppearances } from '../pdf/normalize-signature-appearances';
|
||||
import { getTeamSettings } from '../team/get-team-settings';
|
||||
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||
import { sendCompletedEmail } from './send-completed-email';
|
||||
|
||||
export type SealDocumentOptions = {
|
||||
documentId: number;
|
||||
sendEmail?: boolean;
|
||||
isResealing?: boolean;
|
||||
requestMetadata?: RequestMetadata;
|
||||
};
|
||||
|
||||
export const sealDocument = async ({
|
||||
documentId,
|
||||
sendEmail = true,
|
||||
isResealing = false,
|
||||
requestMetadata,
|
||||
}: SealDocumentOptions) => {
|
||||
const document = await prisma.document.findFirstOrThrow({
|
||||
where: {
|
||||
id: documentId,
|
||||
},
|
||||
include: {
|
||||
documentData: true,
|
||||
documentMeta: true,
|
||||
recipients: true,
|
||||
},
|
||||
});
|
||||
|
||||
const { documentData } = document;
|
||||
|
||||
if (!documentData) {
|
||||
throw new Error(`Document ${document.id} has no document data`);
|
||||
}
|
||||
|
||||
const settings = await getTeamSettings({
|
||||
userId: document.userId,
|
||||
teamId: document.teamId,
|
||||
});
|
||||
|
||||
const recipients = await prisma.recipient.findMany({
|
||||
where: {
|
||||
documentId: document.id,
|
||||
role: {
|
||||
not: RecipientRole.CC,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Determine if the document has been rejected by checking if any recipient has rejected it
|
||||
const rejectedRecipient = recipients.find(
|
||||
(recipient) => recipient.signingStatus === SigningStatus.REJECTED,
|
||||
);
|
||||
|
||||
const isRejected = Boolean(rejectedRecipient);
|
||||
|
||||
// Get the rejection reason from the rejected recipient
|
||||
const rejectionReason = rejectedRecipient?.rejectionReason ?? '';
|
||||
|
||||
// If the document is not rejected, ensure all recipients have signed
|
||||
if (
|
||||
!isRejected &&
|
||||
recipients.some((recipient) => recipient.signingStatus !== SigningStatus.SIGNED)
|
||||
) {
|
||||
throw new Error(`Document ${document.id} has unsigned recipients`);
|
||||
}
|
||||
|
||||
const fields = await prisma.field.findMany({
|
||||
where: {
|
||||
documentId: document.id,
|
||||
},
|
||||
include: {
|
||||
signature: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Skip the field check if the document is rejected
|
||||
if (!isRejected && fieldsContainUnsignedRequiredField(fields)) {
|
||||
throw new Error(`Document ${document.id} has unsigned required fields`);
|
||||
}
|
||||
|
||||
if (isResealing) {
|
||||
// If we're resealing we want to use the initial data for the document
|
||||
// so we aren't placing fields on top of eachother.
|
||||
documentData.data = documentData.initialData;
|
||||
}
|
||||
|
||||
// !: Need to write the fields onto the document as a hard copy
|
||||
const pdfData = await getFileServerSide(documentData);
|
||||
|
||||
const certificateData = settings.includeSigningCertificate
|
||||
? await getCertificatePdf({
|
||||
documentId,
|
||||
language: document.documentMeta?.language,
|
||||
}).catch((e) => {
|
||||
console.log('Failed to get certificate PDF');
|
||||
console.error(e);
|
||||
|
||||
return null;
|
||||
})
|
||||
: null;
|
||||
|
||||
const auditLogData = settings.includeAuditLog
|
||||
? await getAuditLogsPdf({
|
||||
documentId,
|
||||
language: document.documentMeta?.language,
|
||||
}).catch((e) => {
|
||||
console.log('Failed to get audit logs PDF');
|
||||
console.error(e);
|
||||
|
||||
return null;
|
||||
})
|
||||
: null;
|
||||
|
||||
const doc = await PDFDocument.load(pdfData);
|
||||
|
||||
// Normalize and flatten layers that could cause issues with the signature
|
||||
normalizeSignatureAppearances(doc);
|
||||
await flattenForm(doc);
|
||||
flattenAnnotations(doc);
|
||||
|
||||
// Add rejection stamp if the document is rejected
|
||||
if (isRejected && rejectionReason) {
|
||||
await addRejectionStampToPdf(doc, rejectionReason);
|
||||
}
|
||||
|
||||
if (certificateData) {
|
||||
const certificate = await PDFDocument.load(certificateData);
|
||||
|
||||
const certificatePages = await doc.copyPages(certificate, certificate.getPageIndices());
|
||||
|
||||
certificatePages.forEach((page) => {
|
||||
doc.addPage(page);
|
||||
});
|
||||
}
|
||||
|
||||
if (auditLogData) {
|
||||
const auditLog = await PDFDocument.load(auditLogData);
|
||||
|
||||
const auditLogPages = await doc.copyPages(auditLog, auditLog.getPageIndices());
|
||||
|
||||
auditLogPages.forEach((page) => {
|
||||
doc.addPage(page);
|
||||
});
|
||||
}
|
||||
|
||||
for (const field of fields) {
|
||||
document.useLegacyFieldInsertion
|
||||
? await legacy_insertFieldInPDF(doc, field)
|
||||
: await insertFieldInPDF(doc, field);
|
||||
}
|
||||
|
||||
// Re-flatten post-insertion to handle fields that create arcoFields
|
||||
await flattenForm(doc);
|
||||
|
||||
const pdfBytes = await doc.save();
|
||||
|
||||
const pdfBuffer = await signPdf({ pdf: Buffer.from(pdfBytes) });
|
||||
|
||||
const { name } = path.parse(document.title);
|
||||
|
||||
// Add suffix based on document status
|
||||
const suffix = isRejected ? '_rejected.pdf' : '_signed.pdf';
|
||||
|
||||
const { data: newData } = await putPdfFileServerSide({
|
||||
name: `${name}${suffix}`,
|
||||
type: 'application/pdf',
|
||||
arrayBuffer: async () => Promise.resolve(pdfBuffer),
|
||||
});
|
||||
|
||||
const postHog = PostHogServerClient();
|
||||
|
||||
if (postHog) {
|
||||
postHog.capture({
|
||||
distinctId: nanoid(),
|
||||
event: 'App: Document Sealed',
|
||||
properties: {
|
||||
documentId: document.id,
|
||||
isRejected,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.document.update({
|
||||
where: {
|
||||
id: document.id,
|
||||
},
|
||||
data: {
|
||||
status: isRejected ? DocumentStatus.REJECTED : DocumentStatus.COMPLETED,
|
||||
completedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
await tx.documentData.update({
|
||||
where: {
|
||||
id: documentData.id,
|
||||
},
|
||||
data: {
|
||||
data: newData,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_COMPLETED,
|
||||
documentId: document.id,
|
||||
requestMetadata,
|
||||
user: null,
|
||||
data: {
|
||||
transactionId: nanoid(),
|
||||
...(isRejected ? { isRejected: true, rejectionReason } : {}),
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
if (sendEmail && !isResealing) {
|
||||
await sendCompletedEmail({ documentId, requestMetadata });
|
||||
}
|
||||
|
||||
const updatedDocument = await prisma.document.findFirstOrThrow({
|
||||
where: {
|
||||
id: document.id,
|
||||
},
|
||||
include: {
|
||||
documentData: true,
|
||||
documentMeta: true,
|
||||
recipients: true,
|
||||
},
|
||||
});
|
||||
|
||||
await triggerWebhook({
|
||||
event: isRejected
|
||||
? WebhookTriggerEvents.DOCUMENT_REJECTED
|
||||
: WebhookTriggerEvents.DOCUMENT_COMPLETED,
|
||||
data: ZWebhookDocumentSchema.parse(mapDocumentToWebhookDocumentPayload(updatedDocument)),
|
||||
userId: document.userId,
|
||||
teamId: document.teamId ?? undefined,
|
||||
});
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Document, Recipient, User } from '@prisma/client';
|
||||
import { DocumentStatus, DocumentVisibility, TeamMemberRole } from '@prisma/client';
|
||||
import { DocumentStatus, EnvelopeType } from '@prisma/client';
|
||||
import type { Envelope, Recipient, User } from '@prisma/client';
|
||||
import { DocumentVisibility, TeamMemberRole } from '@prisma/client';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import {
|
||||
@@ -9,6 +10,8 @@ import {
|
||||
} from '@documenso/lib/utils/teams';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { mapSecondaryIdToDocumentId } from '../../utils/envelope';
|
||||
|
||||
export type SearchDocumentsWithKeywordOptions = {
|
||||
query: string;
|
||||
userId: number;
|
||||
@@ -26,8 +29,9 @@ export const searchDocumentsWithKeyword = async ({
|
||||
},
|
||||
});
|
||||
|
||||
const documents = await prisma.document.findMany({
|
||||
const envelopes = await prisma.envelope.findMany({
|
||||
where: {
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
OR: [
|
||||
{
|
||||
title: {
|
||||
@@ -128,26 +132,26 @@ export const searchDocumentsWithKeyword = async ({
|
||||
take: limit,
|
||||
});
|
||||
|
||||
const isOwner = (document: Document, user: User) => document.userId === user.id;
|
||||
const isOwner = (envelope: Envelope, user: User) => envelope.userId === user.id;
|
||||
|
||||
const getSigningLink = (recipients: Recipient[], user: User) =>
|
||||
`/sign/${recipients.find((r) => r.email === user.email)?.token}`;
|
||||
|
||||
const maskedDocuments = documents
|
||||
.filter((document) => {
|
||||
if (!document.teamId || isOwner(document, user)) {
|
||||
const maskedDocuments = envelopes
|
||||
.filter((envelope) => {
|
||||
if (!envelope.teamId || isOwner(envelope, user)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const teamMemberRole = getHighestTeamRoleInGroup(
|
||||
document.team.teamGroups.filter((tg) => tg.teamId === document.teamId),
|
||||
envelope.team.teamGroups.filter((tg) => tg.teamId === envelope.teamId),
|
||||
);
|
||||
|
||||
if (!teamMemberRole) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const canAccessDocument = match([document.visibility, teamMemberRole])
|
||||
const canAccessDocument = match([envelope.visibility, teamMemberRole])
|
||||
.with([DocumentVisibility.EVERYONE, TeamMemberRole.ADMIN], () => true)
|
||||
.with([DocumentVisibility.EVERYONE, TeamMemberRole.MANAGER], () => true)
|
||||
.with([DocumentVisibility.EVERYONE, TeamMemberRole.MEMBER], () => true)
|
||||
@@ -158,23 +162,29 @@ export const searchDocumentsWithKeyword = async ({
|
||||
|
||||
return canAccessDocument;
|
||||
})
|
||||
.map((document) => {
|
||||
const { recipients, ...documentWithoutRecipient } = document;
|
||||
.map((envelope) => {
|
||||
const { recipients, ...documentWithoutRecipient } = envelope;
|
||||
|
||||
let documentPath;
|
||||
|
||||
if (isOwner(document, user)) {
|
||||
documentPath = `${formatDocumentsPath(document.team?.url)}/${document.id}`;
|
||||
} else if (document.teamId && document.team.teamGroups.length > 0) {
|
||||
documentPath = `${formatDocumentsPath(document.team.url)}/${document.id}`;
|
||||
const legacyDocumentId = mapSecondaryIdToDocumentId(envelope.secondaryId);
|
||||
|
||||
if (isOwner(envelope, user)) {
|
||||
documentPath = `${formatDocumentsPath(envelope.team.url)}/${legacyDocumentId}`;
|
||||
} else if (envelope.teamId && envelope.team.teamGroups.length > 0) {
|
||||
documentPath = `${formatDocumentsPath(envelope.team.url)}/${legacyDocumentId}`;
|
||||
} else {
|
||||
documentPath = getSigningLink(recipients, user);
|
||||
}
|
||||
|
||||
return {
|
||||
...documentWithoutRecipient,
|
||||
team: {
|
||||
id: envelope.teamId,
|
||||
url: envelope.team.url,
|
||||
},
|
||||
path: documentPath,
|
||||
value: [document.id, document.title, ...document.recipients.map((r) => r.email)].join(' '),
|
||||
value: [envelope.id, envelope.title, ...envelope.recipients.map((r) => r.email)].join(' '),
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createElement } from 'react';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { DocumentSource } from '@prisma/client';
|
||||
import { DocumentSource, EnvelopeType } from '@prisma/client';
|
||||
|
||||
import { mailer } from '@documenso/email/mailer';
|
||||
import { DocumentCompletedEmailTemplate } from '@documenso/email/templates/document-completed';
|
||||
@@ -14,23 +14,33 @@ import { extractDerivedDocumentEmailSettings } from '../../types/document-email'
|
||||
import type { RequestMetadata } from '../../universal/extract-request-metadata';
|
||||
import { getFileServerSide } from '../../universal/upload/get-file.server';
|
||||
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
|
||||
import type { EnvelopeIdOptions } from '../../utils/envelope';
|
||||
import { unsafeBuildEnvelopeIdQuery } from '../../utils/envelope';
|
||||
import { renderCustomEmailTemplate } from '../../utils/render-custom-email-template';
|
||||
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
|
||||
import { formatDocumentsPath } from '../../utils/teams';
|
||||
import { getEmailContext } from '../email/get-email-context';
|
||||
|
||||
export interface SendDocumentOptions {
|
||||
documentId: number;
|
||||
id: EnvelopeIdOptions;
|
||||
requestMetadata?: RequestMetadata;
|
||||
}
|
||||
|
||||
export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDocumentOptions) => {
|
||||
const document = await prisma.document.findUnique({
|
||||
where: {
|
||||
id: documentId,
|
||||
},
|
||||
export const sendCompletedEmail = async ({ id, requestMetadata }: SendDocumentOptions) => {
|
||||
const envelope = await prisma.envelope.findUnique({
|
||||
where: unsafeBuildEnvelopeIdQuery(id, EnvelopeType.DOCUMENT),
|
||||
include: {
|
||||
documentData: true,
|
||||
envelopeItems: {
|
||||
include: {
|
||||
documentData: {
|
||||
select: {
|
||||
type: true,
|
||||
id: true,
|
||||
data: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
documentMeta: true,
|
||||
recipients: true,
|
||||
user: {
|
||||
@@ -49,13 +59,13 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo
|
||||
},
|
||||
});
|
||||
|
||||
if (!document) {
|
||||
if (!envelope) {
|
||||
throw new Error('Document not found');
|
||||
}
|
||||
|
||||
const isDirectTemplate = document?.source === DocumentSource.TEMPLATE_DIRECT_LINK;
|
||||
const isDirectTemplate = envelope?.source === DocumentSource.TEMPLATE_DIRECT_LINK;
|
||||
|
||||
if (document.recipients.length === 0) {
|
||||
if (envelope.recipients.length === 0) {
|
||||
throw new Error('Document has no recipients');
|
||||
}
|
||||
|
||||
@@ -63,28 +73,37 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo
|
||||
emailType: 'RECIPIENT',
|
||||
source: {
|
||||
type: 'team',
|
||||
teamId: document.teamId,
|
||||
teamId: envelope.teamId,
|
||||
},
|
||||
meta: document.documentMeta,
|
||||
meta: envelope.documentMeta,
|
||||
});
|
||||
|
||||
const { user: owner } = document;
|
||||
const { user: owner } = envelope;
|
||||
|
||||
const completedDocument = await getFileServerSide(document.documentData);
|
||||
const completedDocumentEmailAttachments = await Promise.all(
|
||||
envelope.envelopeItems.map(async (document) => {
|
||||
const file = await getFileServerSide(document.documentData);
|
||||
|
||||
return {
|
||||
fileName: document.title.endsWith('.pdf') ? document.title : document.title + '.pdf',
|
||||
content: Buffer.from(file),
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
|
||||
|
||||
let documentOwnerDownloadLink = `${NEXT_PUBLIC_WEBAPP_URL()}${formatDocumentsPath(
|
||||
document.team?.url,
|
||||
)}/${document.id}`;
|
||||
envelope.team?.url,
|
||||
)}/${envelope.id}`;
|
||||
|
||||
if (document.team?.url) {
|
||||
documentOwnerDownloadLink = `${NEXT_PUBLIC_WEBAPP_URL()}/t/${document.team.url}/documents/${
|
||||
document.id
|
||||
if (envelope.team?.url) {
|
||||
documentOwnerDownloadLink = `${NEXT_PUBLIC_WEBAPP_URL()}/t/${envelope.team.url}/documents/${
|
||||
envelope.id
|
||||
}`;
|
||||
}
|
||||
|
||||
const emailSettings = extractDerivedDocumentEmailSettings(document.documentMeta);
|
||||
const emailSettings = extractDerivedDocumentEmailSettings(envelope.documentMeta);
|
||||
const isDocumentCompletedEmailEnabled = emailSettings.documentCompleted;
|
||||
const isOwnerDocumentCompletedEmailEnabled = emailSettings.ownerDocumentCompleted;
|
||||
|
||||
@@ -95,11 +114,11 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo
|
||||
// - Recipient emails are disabled
|
||||
if (
|
||||
isOwnerDocumentCompletedEmailEnabled &&
|
||||
(!document.recipients.find((recipient) => recipient.email === owner.email) ||
|
||||
(!envelope.recipients.find((recipient) => recipient.email === owner.email) ||
|
||||
!isDocumentCompletedEmailEnabled)
|
||||
) {
|
||||
const template = createElement(DocumentCompletedEmailTemplate, {
|
||||
documentName: document.title,
|
||||
documentName: envelope.title,
|
||||
assetBaseUrl,
|
||||
downloadLink: documentOwnerDownloadLink,
|
||||
});
|
||||
@@ -127,18 +146,13 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo
|
||||
subject: i18n._(msg`Signing Complete!`),
|
||||
html,
|
||||
text,
|
||||
attachments: [
|
||||
{
|
||||
filename: document.title.endsWith('.pdf') ? document.title : document.title + '.pdf',
|
||||
content: Buffer.from(completedDocument),
|
||||
},
|
||||
],
|
||||
attachments: completedDocumentEmailAttachments,
|
||||
});
|
||||
|
||||
await prisma.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
|
||||
documentId: document.id,
|
||||
envelopeId: envelope.id,
|
||||
user: null,
|
||||
requestMetadata,
|
||||
data: {
|
||||
@@ -158,22 +172,22 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
document.recipients.map(async (recipient) => {
|
||||
envelope.recipients.map(async (recipient) => {
|
||||
const customEmailTemplate = {
|
||||
'signer.name': recipient.name,
|
||||
'signer.email': recipient.email,
|
||||
'document.name': document.title,
|
||||
'document.name': envelope.title,
|
||||
};
|
||||
|
||||
const downloadLink = `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}/complete`;
|
||||
|
||||
const template = createElement(DocumentCompletedEmailTemplate, {
|
||||
documentName: document.title,
|
||||
documentName: envelope.title,
|
||||
assetBaseUrl,
|
||||
downloadLink: recipient.email === owner.email ? documentOwnerDownloadLink : downloadLink,
|
||||
customBody:
|
||||
isDirectTemplate && document.documentMeta?.message
|
||||
? renderCustomEmailTemplate(document.documentMeta.message, customEmailTemplate)
|
||||
isDirectTemplate && envelope.documentMeta?.message
|
||||
? renderCustomEmailTemplate(envelope.documentMeta.message, customEmailTemplate)
|
||||
: undefined,
|
||||
});
|
||||
|
||||
@@ -198,23 +212,18 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo
|
||||
from: senderEmail,
|
||||
replyTo: replyToEmail,
|
||||
subject:
|
||||
isDirectTemplate && document.documentMeta?.subject
|
||||
? renderCustomEmailTemplate(document.documentMeta.subject, customEmailTemplate)
|
||||
isDirectTemplate && envelope.documentMeta?.subject
|
||||
? renderCustomEmailTemplate(envelope.documentMeta.subject, customEmailTemplate)
|
||||
: i18n._(msg`Signing Complete!`),
|
||||
html,
|
||||
text,
|
||||
attachments: [
|
||||
{
|
||||
filename: document.title.endsWith('.pdf') ? document.title : document.title + '.pdf',
|
||||
content: Buffer.from(completedDocument),
|
||||
},
|
||||
],
|
||||
attachments: completedDocumentEmailAttachments,
|
||||
});
|
||||
|
||||
await prisma.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
|
||||
documentId: document.id,
|
||||
envelopeId: envelope.id,
|
||||
user: null,
|
||||
requestMetadata,
|
||||
data: {
|
||||
|
||||
@@ -14,14 +14,15 @@ import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
|
||||
import { getEmailContext } from '../email/get-email-context';
|
||||
|
||||
export interface SendDeleteEmailOptions {
|
||||
documentId: number;
|
||||
envelopeId: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export const sendDeleteEmail = async ({ documentId, reason }: SendDeleteEmailOptions) => {
|
||||
const document = await prisma.document.findFirst({
|
||||
// Note: Currently only sent by Admin function
|
||||
export const sendDeleteEmail = async ({ envelopeId, reason }: SendDeleteEmailOptions) => {
|
||||
const envelope = await prisma.envelope.findFirst({
|
||||
where: {
|
||||
id: documentId,
|
||||
id: envelopeId,
|
||||
},
|
||||
include: {
|
||||
user: {
|
||||
@@ -35,14 +36,14 @@ export const sendDeleteEmail = async ({ documentId, reason }: SendDeleteEmailOpt
|
||||
},
|
||||
});
|
||||
|
||||
if (!document) {
|
||||
if (!envelope) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Document not found',
|
||||
});
|
||||
}
|
||||
|
||||
const isDocumentDeletedEmailEnabled = extractDerivedDocumentEmailSettings(
|
||||
document.documentMeta,
|
||||
envelope.documentMeta,
|
||||
).documentDeleted;
|
||||
|
||||
if (!isDocumentDeletedEmailEnabled) {
|
||||
@@ -53,17 +54,17 @@ export const sendDeleteEmail = async ({ documentId, reason }: SendDeleteEmailOpt
|
||||
emailType: 'INTERNAL',
|
||||
source: {
|
||||
type: 'team',
|
||||
teamId: document.teamId,
|
||||
teamId: envelope.teamId,
|
||||
},
|
||||
meta: document.documentMeta,
|
||||
meta: envelope.documentMeta,
|
||||
});
|
||||
|
||||
const { email, name } = document.user;
|
||||
const { email, name } = envelope.user;
|
||||
|
||||
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
|
||||
|
||||
const template = createElement(DocumentSuperDeleteEmailTemplate, {
|
||||
documentName: document.title,
|
||||
documentName: envelope.title,
|
||||
reason,
|
||||
assetBaseUrl,
|
||||
});
|
||||
|
||||
+90
-65
@@ -1,6 +1,8 @@
|
||||
import type { DocumentData, Envelope, EnvelopeItem } from '@prisma/client';
|
||||
import {
|
||||
DocumentSigningOrder,
|
||||
DocumentStatus,
|
||||
EnvelopeType,
|
||||
RecipientRole,
|
||||
SendStatus,
|
||||
SigningStatus,
|
||||
@@ -16,17 +18,18 @@ import { jobs } from '../../jobs/client';
|
||||
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
|
||||
import {
|
||||
ZWebhookDocumentSchema,
|
||||
mapDocumentToWebhookDocumentPayload,
|
||||
mapEnvelopeToWebhookDocumentPayload,
|
||||
} from '../../types/webhook-payload';
|
||||
import { getFileServerSide } from '../../universal/upload/get-file.server';
|
||||
import { putPdfFileServerSide } from '../../universal/upload/put-file.server';
|
||||
import { isDocumentCompleted } from '../../utils/document';
|
||||
import { type EnvelopeIdOptions, mapSecondaryIdToDocumentId } from '../../utils/envelope';
|
||||
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
|
||||
import { insertFormValuesInPdf } from '../pdf/insert-form-values-in-pdf';
|
||||
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||
import { getDocumentWhereInput } from './get-document-by-id';
|
||||
|
||||
export type SendDocumentOptions = {
|
||||
documentId: number;
|
||||
id: EnvelopeIdOptions;
|
||||
userId: number;
|
||||
teamId: number;
|
||||
sendEmail?: boolean;
|
||||
@@ -34,93 +37,81 @@ export type SendDocumentOptions = {
|
||||
};
|
||||
|
||||
export const sendDocument = async ({
|
||||
documentId,
|
||||
id,
|
||||
userId,
|
||||
teamId,
|
||||
sendEmail,
|
||||
requestMetadata,
|
||||
}: SendDocumentOptions) => {
|
||||
const { documentWhereInput } = await getDocumentWhereInput({
|
||||
documentId,
|
||||
const { envelopeWhereInput } = await getEnvelopeWhereInput({
|
||||
id,
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
userId,
|
||||
teamId,
|
||||
});
|
||||
|
||||
const document = await prisma.document.findFirst({
|
||||
where: documentWhereInput,
|
||||
const envelope = await prisma.envelope.findFirst({
|
||||
where: envelopeWhereInput,
|
||||
include: {
|
||||
recipients: {
|
||||
orderBy: [{ signingOrder: { sort: 'asc', nulls: 'last' } }, { id: 'asc' }],
|
||||
},
|
||||
documentMeta: true,
|
||||
documentData: true,
|
||||
envelopeItems: {
|
||||
select: {
|
||||
id: true,
|
||||
documentData: {
|
||||
select: {
|
||||
type: true,
|
||||
id: true,
|
||||
data: true,
|
||||
initialData: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!document) {
|
||||
if (!envelope) {
|
||||
throw new Error('Document not found');
|
||||
}
|
||||
|
||||
if (document.recipients.length === 0) {
|
||||
if (envelope.recipients.length === 0) {
|
||||
throw new Error('Document has no recipients');
|
||||
}
|
||||
|
||||
if (isDocumentCompleted(document.status)) {
|
||||
if (isDocumentCompleted(envelope.status)) {
|
||||
throw new Error('Can not send completed document');
|
||||
}
|
||||
|
||||
const signingOrder = document.documentMeta?.signingOrder || DocumentSigningOrder.PARALLEL;
|
||||
const legacyDocumentId = mapSecondaryIdToDocumentId(envelope.secondaryId);
|
||||
|
||||
let recipientsToNotify = document.recipients;
|
||||
const signingOrder = envelope.documentMeta?.signingOrder || DocumentSigningOrder.PARALLEL;
|
||||
|
||||
let recipientsToNotify = envelope.recipients;
|
||||
|
||||
if (signingOrder === DocumentSigningOrder.SEQUENTIAL) {
|
||||
// Get the currently active recipient.
|
||||
recipientsToNotify = document.recipients
|
||||
recipientsToNotify = envelope.recipients
|
||||
.filter((r) => r.signingStatus === SigningStatus.NOT_SIGNED && r.role !== RecipientRole.CC)
|
||||
.slice(0, 1);
|
||||
|
||||
// Secondary filter so we aren't resending if the current active recipient has already
|
||||
// received the document.
|
||||
// received the envelope.
|
||||
recipientsToNotify.filter((r) => r.sendStatus !== SendStatus.SENT);
|
||||
}
|
||||
|
||||
const { documentData } = document;
|
||||
|
||||
if (!documentData.data) {
|
||||
throw new Error('Document data not found');
|
||||
if (envelope.envelopeItems.length === 0) {
|
||||
throw new Error('Missing envelope items');
|
||||
}
|
||||
|
||||
if (document.formValues) {
|
||||
const file = await getFileServerSide(documentData);
|
||||
|
||||
const prefilled = await insertFormValuesInPdf({
|
||||
pdf: Buffer.from(file),
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
formValues: document.formValues as Record<string, string | number | boolean>,
|
||||
});
|
||||
|
||||
let fileName = document.title;
|
||||
|
||||
if (!document.title.endsWith('.pdf')) {
|
||||
fileName = `${document.title}.pdf`;
|
||||
}
|
||||
|
||||
const newDocumentData = await putPdfFileServerSide({
|
||||
name: fileName,
|
||||
type: 'application/pdf',
|
||||
arrayBuffer: async () => Promise.resolve(prefilled),
|
||||
});
|
||||
|
||||
const result = await prisma.document.update({
|
||||
where: {
|
||||
id: document.id,
|
||||
},
|
||||
data: {
|
||||
documentDataId: newDocumentData.id,
|
||||
},
|
||||
});
|
||||
|
||||
Object.assign(document, result);
|
||||
if (envelope.formValues) {
|
||||
await Promise.all(
|
||||
envelope.envelopeItems.map(async (envelopeItem) => {
|
||||
await injectFormValuesIntoDocument(envelope, envelopeItem);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Commented out server side checks for minimum 1 signature per signer now since we need to
|
||||
@@ -133,7 +124,7 @@ export const sendDocument = async ({
|
||||
// const fieldsWithSignerEmail = fields.map((field) => ({
|
||||
// ...field,
|
||||
// signerEmail:
|
||||
// document.Recipient.find((recipient) => recipient.id === field.recipientId)?.email ?? '',
|
||||
// envelope.Recipient.find((recipient) => recipient.id === field.recipientId)?.email ?? '',
|
||||
// }));
|
||||
|
||||
// const everySignerHasSignature = document?.Recipient.every(
|
||||
@@ -148,7 +139,7 @@ export const sendDocument = async ({
|
||||
// throw new Error('Some signers have not been assigned a signature field.');
|
||||
// }
|
||||
|
||||
const allRecipientsHaveNoActionToTake = document.recipients.every(
|
||||
const allRecipientsHaveNoActionToTake = envelope.recipients.every(
|
||||
(recipient) =>
|
||||
recipient.role === RecipientRole.CC || recipient.signingStatus === SigningStatus.SIGNED,
|
||||
);
|
||||
@@ -157,15 +148,15 @@ export const sendDocument = async ({
|
||||
await jobs.triggerJob({
|
||||
name: 'internal.seal-document',
|
||||
payload: {
|
||||
documentId,
|
||||
documentId: legacyDocumentId,
|
||||
requestMetadata: requestMetadata?.requestMetadata,
|
||||
},
|
||||
});
|
||||
|
||||
// Keep the return type the same for the `sendDocument` method
|
||||
return await prisma.document.findFirstOrThrow({
|
||||
return await prisma.envelope.findFirstOrThrow({
|
||||
where: {
|
||||
id: documentId,
|
||||
id: envelope.id,
|
||||
},
|
||||
include: {
|
||||
documentMeta: true,
|
||||
@@ -174,21 +165,21 @@ export const sendDocument = async ({
|
||||
});
|
||||
}
|
||||
|
||||
const updatedDocument = await prisma.$transaction(async (tx) => {
|
||||
if (document.status === DocumentStatus.DRAFT) {
|
||||
const updatedEnvelope = await prisma.$transaction(async (tx) => {
|
||||
if (envelope.status === DocumentStatus.DRAFT) {
|
||||
await tx.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_SENT,
|
||||
documentId: document.id,
|
||||
envelopeId: envelope.id,
|
||||
metadata: requestMetadata,
|
||||
data: {},
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
return await tx.document.update({
|
||||
return await tx.envelope.update({
|
||||
where: {
|
||||
id: documentId,
|
||||
id: envelope.id,
|
||||
},
|
||||
data: {
|
||||
status: DocumentStatus.PENDING,
|
||||
@@ -201,7 +192,7 @@ export const sendDocument = async ({
|
||||
});
|
||||
|
||||
const isRecipientSigningRequestEmailEnabled = extractDerivedDocumentEmailSettings(
|
||||
document.documentMeta,
|
||||
envelope.documentMeta,
|
||||
).recipientSigningRequest;
|
||||
|
||||
// Only send email if one of the following is true:
|
||||
@@ -218,7 +209,7 @@ export const sendDocument = async ({
|
||||
name: 'send.signing.requested.email',
|
||||
payload: {
|
||||
userId,
|
||||
documentId,
|
||||
documentId: legacyDocumentId,
|
||||
recipientId: recipient.id,
|
||||
requestMetadata: requestMetadata?.requestMetadata,
|
||||
},
|
||||
@@ -229,10 +220,44 @@ export const sendDocument = async ({
|
||||
|
||||
await triggerWebhook({
|
||||
event: WebhookTriggerEvents.DOCUMENT_SENT,
|
||||
data: ZWebhookDocumentSchema.parse(mapDocumentToWebhookDocumentPayload(updatedDocument)),
|
||||
data: ZWebhookDocumentSchema.parse(mapEnvelopeToWebhookDocumentPayload(updatedEnvelope)),
|
||||
userId,
|
||||
teamId,
|
||||
});
|
||||
|
||||
return updatedDocument;
|
||||
return updatedEnvelope;
|
||||
};
|
||||
|
||||
const injectFormValuesIntoDocument = async (
|
||||
envelope: Envelope,
|
||||
envelopeItem: Pick<EnvelopeItem, 'id'> & { documentData: DocumentData },
|
||||
) => {
|
||||
const file = await getFileServerSide(envelopeItem.documentData);
|
||||
|
||||
const prefilled = await insertFormValuesInPdf({
|
||||
pdf: Buffer.from(file),
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
formValues: envelope.formValues as Record<string, string | number | boolean>,
|
||||
});
|
||||
|
||||
let fileName = envelope.title;
|
||||
|
||||
if (!envelope.title.endsWith('.pdf')) {
|
||||
fileName = `${envelope.title}.pdf`;
|
||||
}
|
||||
|
||||
const newDocumentData = await putPdfFileServerSide({
|
||||
name: fileName,
|
||||
type: 'application/pdf',
|
||||
arrayBuffer: async () => Promise.resolve(prefilled),
|
||||
});
|
||||
|
||||
await prisma.envelopeItem.update({
|
||||
where: {
|
||||
id: envelopeItem.id,
|
||||
},
|
||||
data: {
|
||||
documentDataId: newDocumentData.id,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createElement } from 'react';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { EnvelopeType } from '@prisma/client';
|
||||
|
||||
import { mailer } from '@documenso/email/mailer';
|
||||
import { DocumentPendingEmailTemplate } from '@documenso/email/templates/document-pending';
|
||||
@@ -9,18 +10,20 @@ 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 type { EnvelopeIdOptions } from '../../utils/envelope';
|
||||
import { unsafeBuildEnvelopeIdQuery } from '../../utils/envelope';
|
||||
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
|
||||
import { getEmailContext } from '../email/get-email-context';
|
||||
|
||||
export interface SendPendingEmailOptions {
|
||||
documentId: number;
|
||||
id: EnvelopeIdOptions;
|
||||
recipientId: number;
|
||||
}
|
||||
|
||||
export const sendPendingEmail = async ({ documentId, recipientId }: SendPendingEmailOptions) => {
|
||||
const document = await prisma.document.findFirst({
|
||||
export const sendPendingEmail = async ({ id, recipientId }: SendPendingEmailOptions) => {
|
||||
const envelope = await prisma.envelope.findFirst({
|
||||
where: {
|
||||
id: documentId,
|
||||
...unsafeBuildEnvelopeIdQuery(id, EnvelopeType.DOCUMENT),
|
||||
recipients: {
|
||||
some: {
|
||||
id: recipientId,
|
||||
@@ -37,11 +40,11 @@ export const sendPendingEmail = async ({ documentId, recipientId }: SendPendingE
|
||||
},
|
||||
});
|
||||
|
||||
if (!document) {
|
||||
if (!envelope) {
|
||||
throw new Error('Document not found');
|
||||
}
|
||||
|
||||
if (document.recipients.length === 0) {
|
||||
if (envelope.recipients.length === 0) {
|
||||
throw new Error('Document has no recipients');
|
||||
}
|
||||
|
||||
@@ -49,27 +52,27 @@ export const sendPendingEmail = async ({ documentId, recipientId }: SendPendingE
|
||||
emailType: 'RECIPIENT',
|
||||
source: {
|
||||
type: 'team',
|
||||
teamId: document.teamId,
|
||||
teamId: envelope.teamId,
|
||||
},
|
||||
meta: document.documentMeta,
|
||||
meta: envelope.documentMeta,
|
||||
});
|
||||
|
||||
const isDocumentPendingEmailEnabled = extractDerivedDocumentEmailSettings(
|
||||
document.documentMeta,
|
||||
envelope.documentMeta,
|
||||
).documentPending;
|
||||
|
||||
if (!isDocumentPendingEmailEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [recipient] = document.recipients;
|
||||
const [recipient] = envelope.recipients;
|
||||
|
||||
const { email, name } = recipient;
|
||||
|
||||
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
|
||||
|
||||
const template = createElement(DocumentPendingEmailTemplate, {
|
||||
documentName: document.title,
|
||||
documentName: envelope.title,
|
||||
assetBaseUrl,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,248 +0,0 @@
|
||||
import { DocumentVisibility } from '@prisma/client';
|
||||
import { DocumentStatus, TeamMemberRole } from '@prisma/client';
|
||||
import { isDeepEqual } from 'remeda';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
||||
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||
import type { CreateDocumentAuditLogDataResponse } from '@documenso/lib/utils/document-audit-logs';
|
||||
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import type { TDocumentAccessAuthTypes, TDocumentActionAuthTypes } from '../../types/document-auth';
|
||||
import { createDocumentAuthOptions, extractDocumentAuthMethods } from '../../utils/document-auth';
|
||||
import { getDocumentWhereInput } from './get-document-by-id';
|
||||
|
||||
export type UpdateDocumentOptions = {
|
||||
userId: number;
|
||||
teamId: number;
|
||||
documentId: number;
|
||||
data?: {
|
||||
title?: string;
|
||||
externalId?: string | null;
|
||||
visibility?: DocumentVisibility | null;
|
||||
globalAccessAuth?: TDocumentAccessAuthTypes[];
|
||||
globalActionAuth?: TDocumentActionAuthTypes[];
|
||||
useLegacyFieldInsertion?: boolean;
|
||||
};
|
||||
requestMetadata: ApiRequestMetadata;
|
||||
};
|
||||
|
||||
export const updateDocument = async ({
|
||||
userId,
|
||||
teamId,
|
||||
documentId,
|
||||
data,
|
||||
requestMetadata,
|
||||
}: UpdateDocumentOptions) => {
|
||||
const { documentWhereInput, team } = await getDocumentWhereInput({
|
||||
documentId,
|
||||
userId,
|
||||
teamId,
|
||||
});
|
||||
|
||||
const document = await prisma.document.findFirst({
|
||||
where: documentWhereInput,
|
||||
include: {
|
||||
team: {
|
||||
select: {
|
||||
organisation: {
|
||||
select: {
|
||||
organisationClaim: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!document) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Document not found',
|
||||
});
|
||||
}
|
||||
|
||||
const isDocumentOwner = document.userId === userId;
|
||||
const requestedVisibility = data?.visibility;
|
||||
|
||||
if (!isDocumentOwner) {
|
||||
match(team.currentTeamRole)
|
||||
.with(TeamMemberRole.ADMIN, () => true)
|
||||
.with(TeamMemberRole.MANAGER, () => {
|
||||
const allowedVisibilities: DocumentVisibility[] = [
|
||||
DocumentVisibility.EVERYONE,
|
||||
DocumentVisibility.MANAGER_AND_ABOVE,
|
||||
];
|
||||
|
||||
if (
|
||||
!allowedVisibilities.includes(document.visibility) ||
|
||||
(requestedVisibility && !allowedVisibilities.includes(requestedVisibility))
|
||||
) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'You do not have permission to update the document visibility',
|
||||
});
|
||||
}
|
||||
})
|
||||
.with(TeamMemberRole.MEMBER, () => {
|
||||
if (
|
||||
document.visibility !== DocumentVisibility.EVERYONE ||
|
||||
(requestedVisibility && requestedVisibility !== DocumentVisibility.EVERYONE)
|
||||
) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'You do not have permission to update the document visibility',
|
||||
});
|
||||
}
|
||||
})
|
||||
.otherwise(() => {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'You do not have permission to update the document',
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// If no data just return the document since this function is normally chained after a meta update.
|
||||
if (!data || Object.values(data).length === 0) {
|
||||
return document;
|
||||
}
|
||||
|
||||
const { documentAuthOption } = extractDocumentAuthMethods({
|
||||
documentAuth: document.authOptions,
|
||||
});
|
||||
|
||||
const documentGlobalAccessAuth = documentAuthOption?.globalAccessAuth ?? null;
|
||||
const documentGlobalActionAuth = documentAuthOption?.globalActionAuth ?? null;
|
||||
|
||||
// If the new global auth values aren't passed in, fallback to the current document values.
|
||||
const newGlobalAccessAuth =
|
||||
data?.globalAccessAuth === undefined ? documentGlobalAccessAuth : data.globalAccessAuth;
|
||||
const newGlobalActionAuth =
|
||||
data?.globalActionAuth === undefined ? documentGlobalActionAuth : data.globalActionAuth;
|
||||
|
||||
// Check if user has permission to set the global action auth.
|
||||
if (newGlobalActionAuth.length > 0 && !document.team.organisation.organisationClaim.flags.cfr21) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'You do not have permission to set the action auth',
|
||||
});
|
||||
}
|
||||
|
||||
const isTitleSame = data.title === undefined || data.title === document.title;
|
||||
const isExternalIdSame = data.externalId === undefined || data.externalId === document.externalId;
|
||||
const isGlobalAccessSame =
|
||||
documentGlobalAccessAuth === undefined ||
|
||||
isDeepEqual(documentGlobalAccessAuth, newGlobalAccessAuth);
|
||||
const isGlobalActionSame =
|
||||
documentGlobalActionAuth === undefined ||
|
||||
isDeepEqual(documentGlobalActionAuth, newGlobalActionAuth);
|
||||
const isDocumentVisibilitySame =
|
||||
data.visibility === undefined || data.visibility === document.visibility;
|
||||
|
||||
const auditLogs: CreateDocumentAuditLogDataResponse[] = [];
|
||||
|
||||
if (!isTitleSame && document.status !== DocumentStatus.DRAFT) {
|
||||
throw new AppError(AppErrorCode.INVALID_BODY, {
|
||||
message: 'You cannot update the title if the document has been sent',
|
||||
});
|
||||
}
|
||||
|
||||
if (!isTitleSame) {
|
||||
auditLogs.push(
|
||||
createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_TITLE_UPDATED,
|
||||
documentId,
|
||||
metadata: requestMetadata,
|
||||
data: {
|
||||
from: document.title,
|
||||
to: data.title || '',
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (!isExternalIdSame) {
|
||||
auditLogs.push(
|
||||
createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_EXTERNAL_ID_UPDATED,
|
||||
documentId,
|
||||
metadata: requestMetadata,
|
||||
data: {
|
||||
from: document.externalId,
|
||||
to: data.externalId || '',
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (!isGlobalAccessSame) {
|
||||
auditLogs.push(
|
||||
createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_GLOBAL_AUTH_ACCESS_UPDATED,
|
||||
documentId,
|
||||
metadata: requestMetadata,
|
||||
data: {
|
||||
from: documentGlobalAccessAuth,
|
||||
to: newGlobalAccessAuth,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (!isGlobalActionSame) {
|
||||
auditLogs.push(
|
||||
createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_GLOBAL_AUTH_ACTION_UPDATED,
|
||||
documentId,
|
||||
metadata: requestMetadata,
|
||||
data: {
|
||||
from: documentGlobalActionAuth,
|
||||
to: newGlobalActionAuth,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (!isDocumentVisibilitySame) {
|
||||
auditLogs.push(
|
||||
createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_VISIBILITY_UPDATED,
|
||||
documentId,
|
||||
metadata: requestMetadata,
|
||||
data: {
|
||||
from: document.visibility,
|
||||
to: data.visibility || '',
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Early return if nothing is required.
|
||||
if (auditLogs.length === 0 && data.useLegacyFieldInsertion === undefined) {
|
||||
return document;
|
||||
}
|
||||
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
const authOptions = createDocumentAuthOptions({
|
||||
globalAccessAuth: newGlobalAccessAuth,
|
||||
globalActionAuth: newGlobalActionAuth,
|
||||
});
|
||||
|
||||
const updatedDocument = await tx.document.update({
|
||||
where: {
|
||||
id: documentId,
|
||||
},
|
||||
data: {
|
||||
title: data.title,
|
||||
externalId: data.externalId,
|
||||
visibility: data.visibility as DocumentVisibility,
|
||||
useLegacyFieldInsertion: data.useLegacyFieldInsertion,
|
||||
authOptions,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.documentAuditLog.createMany({
|
||||
data: auditLogs,
|
||||
});
|
||||
|
||||
return updatedDocument;
|
||||
});
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Document, Field, Recipient } from '@prisma/client';
|
||||
import type { Envelope, Field, Recipient } from '@prisma/client';
|
||||
import { FieldType } from '@prisma/client';
|
||||
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
@@ -6,8 +6,8 @@ import type { TRecipientActionAuth } from '../../types/document-auth';
|
||||
import { isRecipientAuthorized } from './is-recipient-authorized';
|
||||
|
||||
export type ValidateFieldAuthOptions = {
|
||||
documentAuthOptions: Document['authOptions'];
|
||||
recipient: Pick<Recipient, 'authOptions' | 'email' | 'documentId'>;
|
||||
documentAuthOptions: Envelope['authOptions'];
|
||||
recipient: Pick<Recipient, 'authOptions' | 'email' | 'envelopeId'>;
|
||||
field: Field;
|
||||
userId?: number;
|
||||
authOptions?: TRecipientActionAuth;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ReadStatus, SendStatus } 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';
|
||||
@@ -9,7 +9,7 @@ import { prisma } from '@documenso/prisma';
|
||||
import type { TDocumentAccessAuthTypes } from '../../types/document-auth';
|
||||
import {
|
||||
ZWebhookDocumentSchema,
|
||||
mapDocumentToWebhookDocumentPayload,
|
||||
mapEnvelopeToWebhookDocumentPayload,
|
||||
} from '../../types/webhook-payload';
|
||||
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||
|
||||
@@ -27,19 +27,30 @@ export const viewedDocument = async ({
|
||||
const recipient = await prisma.recipient.findFirst({
|
||||
where: {
|
||||
token,
|
||||
envelope: {
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
envelope: {
|
||||
include: {
|
||||
documentMeta: true,
|
||||
recipients: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!recipient || !recipient.documentId) {
|
||||
if (!recipient) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { documentId } = recipient;
|
||||
const { envelope } = recipient;
|
||||
|
||||
await prisma.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_VIEWED,
|
||||
documentId,
|
||||
envelopeId: envelope.id,
|
||||
user: {
|
||||
name: recipient.name,
|
||||
email: recipient.email,
|
||||
@@ -75,7 +86,7 @@ export const viewedDocument = async ({
|
||||
await tx.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED,
|
||||
documentId,
|
||||
envelopeId: envelope.id,
|
||||
user: {
|
||||
name: recipient.name,
|
||||
email: recipient.email,
|
||||
@@ -92,24 +103,10 @@ export const viewedDocument = async ({
|
||||
});
|
||||
});
|
||||
|
||||
const document = await prisma.document.findFirst({
|
||||
where: {
|
||||
id: documentId,
|
||||
},
|
||||
include: {
|
||||
documentMeta: true,
|
||||
recipients: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!document) {
|
||||
throw new Error('Document not found');
|
||||
}
|
||||
|
||||
await triggerWebhook({
|
||||
event: WebhookTriggerEvents.DOCUMENT_OPENED,
|
||||
data: ZWebhookDocumentSchema.parse(mapDocumentToWebhookDocumentPayload(document)),
|
||||
userId: document.userId,
|
||||
teamId: document.teamId ?? undefined,
|
||||
data: ZWebhookDocumentSchema.parse(mapEnvelopeToWebhookDocumentPayload(envelope)),
|
||||
userId: envelope.userId,
|
||||
teamId: envelope.teamId,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -0,0 +1,376 @@
|
||||
import type { DocumentMeta, DocumentVisibility, TemplateType } from '@prisma/client';
|
||||
import {
|
||||
DocumentSource,
|
||||
EnvelopeType,
|
||||
FolderType,
|
||||
RecipientRole,
|
||||
SendStatus,
|
||||
SigningStatus,
|
||||
WebhookTriggerEvents,
|
||||
} from '@prisma/client';
|
||||
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { normalizePdf as makeNormalizedPdf } from '@documenso/lib/server-only/pdf/normalize-pdf';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
||||
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||
import { nanoid, prefixedId } from '@documenso/lib/universal/id';
|
||||
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import type { TCreateEnvelopeRequest } from '@documenso/trpc/server/envelope-router/create-envelope.types';
|
||||
|
||||
import type { TDocumentAccessAuthTypes, TDocumentActionAuthTypes } from '../../types/document-auth';
|
||||
import type { TDocumentFormValues } from '../../types/document-form-values';
|
||||
import {
|
||||
ZWebhookDocumentSchema,
|
||||
mapEnvelopeToWebhookDocumentPayload,
|
||||
} from '../../types/webhook-payload';
|
||||
import { getFileServerSide } from '../../universal/upload/get-file.server';
|
||||
import { putPdfFileServerSide } from '../../universal/upload/put-file.server';
|
||||
import { extractDerivedDocumentMeta } from '../../utils/document';
|
||||
import { createDocumentAuthOptions, createRecipientAuthOptions } from '../../utils/document-auth';
|
||||
import { buildTeamWhereQuery } from '../../utils/teams';
|
||||
import { incrementDocumentId, incrementTemplateId } from '../envelope/increment-id';
|
||||
import { getTeamSettings } from '../team/get-team-settings';
|
||||
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||
|
||||
export type CreateEnvelopeOptions = {
|
||||
userId: number;
|
||||
teamId: number;
|
||||
normalizePdf?: boolean;
|
||||
internalVersion: 1 | 2;
|
||||
data: {
|
||||
type: EnvelopeType;
|
||||
title: string;
|
||||
externalId?: string;
|
||||
envelopeItems: { title?: string; documentDataId: string; order?: number }[];
|
||||
formValues?: TDocumentFormValues;
|
||||
|
||||
timezone?: string;
|
||||
userTimezone?: string;
|
||||
|
||||
templateType?: TemplateType;
|
||||
publicTitle?: string;
|
||||
publicDescription?: string;
|
||||
|
||||
visibility?: DocumentVisibility;
|
||||
globalAccessAuth?: TDocumentAccessAuthTypes[];
|
||||
globalActionAuth?: TDocumentActionAuthTypes[];
|
||||
recipients?: TCreateEnvelopeRequest['recipients'];
|
||||
folderId?: string;
|
||||
};
|
||||
meta?: Partial<Omit<DocumentMeta, 'id'>>;
|
||||
requestMetadata: ApiRequestMetadata;
|
||||
};
|
||||
|
||||
export const createEnvelope = async ({
|
||||
userId,
|
||||
teamId,
|
||||
normalizePdf,
|
||||
data,
|
||||
meta,
|
||||
requestMetadata,
|
||||
internalVersion,
|
||||
}: CreateEnvelopeOptions) => {
|
||||
const {
|
||||
type,
|
||||
title,
|
||||
externalId,
|
||||
formValues,
|
||||
timezone,
|
||||
userTimezone,
|
||||
folderId,
|
||||
templateType,
|
||||
globalAccessAuth,
|
||||
globalActionAuth,
|
||||
publicTitle,
|
||||
publicDescription,
|
||||
visibility: visibilityOverride,
|
||||
} = data;
|
||||
|
||||
const team = await prisma.team.findFirst({
|
||||
where: buildTeamWhereQuery({ teamId, userId }),
|
||||
include: {
|
||||
organisation: {
|
||||
select: {
|
||||
organisationClaim: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!team) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Team not found',
|
||||
});
|
||||
}
|
||||
|
||||
// Verify that the folder exists and is associated with the team.
|
||||
if (folderId) {
|
||||
const folder = await prisma.folder.findUnique({
|
||||
where: {
|
||||
id: folderId,
|
||||
type: data.type === EnvelopeType.TEMPLATE ? FolderType.TEMPLATE : FolderType.DOCUMENT,
|
||||
team: buildTeamWhereQuery({ teamId, userId }),
|
||||
},
|
||||
});
|
||||
|
||||
if (!folder) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Folder not found',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const settings = await getTeamSettings({
|
||||
userId,
|
||||
teamId,
|
||||
});
|
||||
|
||||
if (data.envelopeItems.length !== 1 && internalVersion === 1) {
|
||||
throw new AppError(AppErrorCode.INVALID_BODY, {
|
||||
message: 'Envelope items must have exactly 1 item for version 1',
|
||||
});
|
||||
}
|
||||
|
||||
let envelopeItems: { title?: string; documentDataId: string; order?: number }[] =
|
||||
data.envelopeItems;
|
||||
|
||||
if (normalizePdf) {
|
||||
envelopeItems = await Promise.all(
|
||||
data.envelopeItems.map(async (item) => {
|
||||
const documentData = await prisma.documentData.findFirst({
|
||||
where: {
|
||||
id: item.documentDataId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!documentData) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Document data not found',
|
||||
});
|
||||
}
|
||||
|
||||
const buffer = await getFileServerSide(documentData);
|
||||
|
||||
const normalizedPdf = await makeNormalizedPdf(Buffer.from(buffer));
|
||||
|
||||
const titleToUse = item.title || title;
|
||||
|
||||
const newDocumentData = await putPdfFileServerSide({
|
||||
name: titleToUse,
|
||||
type: 'application/pdf',
|
||||
arrayBuffer: async () => Promise.resolve(normalizedPdf),
|
||||
});
|
||||
|
||||
return {
|
||||
title: titleToUse.endsWith('.pdf') ? titleToUse.slice(0, -4) : titleToUse,
|
||||
documentDataId: newDocumentData.id,
|
||||
order: item.order,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const authOptions = createDocumentAuthOptions({
|
||||
globalAccessAuth: globalAccessAuth || [],
|
||||
globalActionAuth: globalActionAuth || [],
|
||||
});
|
||||
|
||||
const recipientsHaveActionAuth = data.recipients?.some(
|
||||
(recipient) => recipient.actionAuth && recipient.actionAuth.length > 0,
|
||||
);
|
||||
|
||||
// Check if user has permission to set the global action auth.
|
||||
if (
|
||||
(authOptions.globalActionAuth.length > 0 || recipientsHaveActionAuth) &&
|
||||
!team.organisation.organisationClaim.flags.cfr21
|
||||
) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'You do not have permission to set the action auth',
|
||||
});
|
||||
}
|
||||
|
||||
const visibility = visibilityOverride || settings.documentVisibility;
|
||||
|
||||
const emailId = meta?.emailId;
|
||||
|
||||
// Validate that the email ID belongs to the organisation.
|
||||
if (emailId) {
|
||||
const email = await prisma.organisationEmail.findFirst({
|
||||
where: {
|
||||
id: emailId,
|
||||
organisationId: team.organisationId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!email) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Email not found',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// userTimezone is last because it's always passed in regardless of the organisation/team settings
|
||||
// for uploads from the frontend
|
||||
const timezoneToUse = timezone || settings.documentTimezone || userTimezone;
|
||||
|
||||
const documentMeta = await prisma.documentMeta.create({
|
||||
data: extractDerivedDocumentMeta(settings, {
|
||||
...meta,
|
||||
timezone: timezoneToUse,
|
||||
}),
|
||||
});
|
||||
|
||||
const secondaryId =
|
||||
type === EnvelopeType.DOCUMENT
|
||||
? await incrementDocumentId().then((v) => v.formattedDocumentId)
|
||||
: await incrementTemplateId().then((v) => v.formattedTemplateId);
|
||||
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
const envelope = await tx.envelope.create({
|
||||
data: {
|
||||
id: prefixedId('envelope'),
|
||||
secondaryId,
|
||||
internalVersion,
|
||||
type,
|
||||
title,
|
||||
qrToken: prefixedId('qr'),
|
||||
externalId,
|
||||
envelopeItems: {
|
||||
createMany: {
|
||||
data: envelopeItems.map((item, i) => ({
|
||||
id: prefixedId('envelope_item'),
|
||||
title: item.title || title,
|
||||
order: item.order !== undefined ? item.order : i + 1,
|
||||
documentDataId: item.documentDataId,
|
||||
})),
|
||||
},
|
||||
},
|
||||
userId,
|
||||
teamId,
|
||||
authOptions,
|
||||
visibility,
|
||||
folderId,
|
||||
formValues,
|
||||
source: type === EnvelopeType.DOCUMENT ? DocumentSource.DOCUMENT : DocumentSource.TEMPLATE,
|
||||
documentMetaId: documentMeta.id,
|
||||
|
||||
// Template specific fields.
|
||||
templateType: type === EnvelopeType.TEMPLATE ? templateType : undefined,
|
||||
publicTitle: type === EnvelopeType.TEMPLATE ? publicTitle : undefined,
|
||||
publicDescription: type === EnvelopeType.TEMPLATE ? publicDescription : undefined,
|
||||
},
|
||||
include: {
|
||||
envelopeItems: true,
|
||||
},
|
||||
});
|
||||
|
||||
const firstEnvelopeItem = envelope.envelopeItems[0];
|
||||
|
||||
await Promise.all(
|
||||
(data.recipients || []).map(async (recipient) => {
|
||||
const recipientAuthOptions = createRecipientAuthOptions({
|
||||
accessAuth: recipient.accessAuth ?? [],
|
||||
actionAuth: recipient.actionAuth ?? [],
|
||||
});
|
||||
|
||||
const recipientFieldsToCreate = (recipient.fields || []).map((field) => {
|
||||
let envelopeItemId = firstEnvelopeItem.id;
|
||||
|
||||
if (field.documentDataId) {
|
||||
const foundEnvelopeItem = envelope.envelopeItems.find(
|
||||
(item) => item.documentDataId === field.documentDataId,
|
||||
);
|
||||
|
||||
if (!foundEnvelopeItem) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Document data not found',
|
||||
});
|
||||
}
|
||||
|
||||
envelopeItemId = foundEnvelopeItem.id;
|
||||
}
|
||||
|
||||
return {
|
||||
envelopeId: envelope.id,
|
||||
envelopeItemId,
|
||||
type: field.type,
|
||||
page: field.page,
|
||||
positionX: field.positionX,
|
||||
positionY: field.positionY,
|
||||
width: field.width,
|
||||
height: field.height,
|
||||
customText: '',
|
||||
inserted: false,
|
||||
fieldMeta: field.fieldMeta || undefined,
|
||||
};
|
||||
});
|
||||
|
||||
await tx.recipient.create({
|
||||
data: {
|
||||
envelopeId: envelope.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: recipientAuthOptions,
|
||||
fields: {
|
||||
createMany: {
|
||||
data: recipientFieldsToCreate,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
const createdEnvelope = await tx.envelope.findFirst({
|
||||
where: {
|
||||
id: envelope.id,
|
||||
},
|
||||
include: {
|
||||
documentMeta: true,
|
||||
recipients: true,
|
||||
fields: true,
|
||||
folder: true,
|
||||
envelopeItems: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!createdEnvelope) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Envelope not found',
|
||||
});
|
||||
}
|
||||
|
||||
// Only create audit logs and webhook events for documents.
|
||||
if (type === EnvelopeType.DOCUMENT) {
|
||||
await tx.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_CREATED,
|
||||
envelopeId: envelope.id,
|
||||
metadata: requestMetadata,
|
||||
data: {
|
||||
title,
|
||||
source: {
|
||||
type: DocumentSource.DOCUMENT,
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
await triggerWebhook({
|
||||
event: WebhookTriggerEvents.DOCUMENT_CREATED,
|
||||
data: ZWebhookDocumentSchema.parse(mapEnvelopeToWebhookDocumentPayload(createdEnvelope)),
|
||||
userId,
|
||||
teamId,
|
||||
});
|
||||
}
|
||||
|
||||
return createdEnvelope;
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,196 @@
|
||||
import { DocumentSource, EnvelopeType, WebhookTriggerEvents } from '@prisma/client';
|
||||
import { omit } from 'remeda';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import {
|
||||
ZWebhookDocumentSchema,
|
||||
mapEnvelopeToWebhookDocumentPayload,
|
||||
} from '../../types/webhook-payload';
|
||||
import { nanoid, prefixedId } from '../../universal/id';
|
||||
import type { EnvelopeIdOptions } from '../../utils/envelope';
|
||||
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
|
||||
import { incrementDocumentId, incrementTemplateId } from '../envelope/increment-id';
|
||||
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||
|
||||
export interface DuplicateEnvelopeOptions {
|
||||
id: EnvelopeIdOptions;
|
||||
userId: number;
|
||||
teamId: number;
|
||||
}
|
||||
|
||||
export const duplicateEnvelope = async ({ id, userId, teamId }: DuplicateEnvelopeOptions) => {
|
||||
const { envelopeWhereInput } = await getEnvelopeWhereInput({
|
||||
id,
|
||||
type: null,
|
||||
userId,
|
||||
teamId,
|
||||
});
|
||||
|
||||
const envelope = await prisma.envelope.findFirst({
|
||||
where: envelopeWhereInput,
|
||||
select: {
|
||||
type: true,
|
||||
title: true,
|
||||
userId: true,
|
||||
internalVersion: true,
|
||||
envelopeItems: {
|
||||
include: {
|
||||
documentData: {
|
||||
select: {
|
||||
data: true,
|
||||
initialData: true,
|
||||
type: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
authOptions: true,
|
||||
visibility: true,
|
||||
documentMeta: true,
|
||||
recipients: {
|
||||
select: {
|
||||
email: true,
|
||||
name: true,
|
||||
role: true,
|
||||
signingOrder: true,
|
||||
fields: true,
|
||||
},
|
||||
},
|
||||
teamId: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!envelope) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Document not found',
|
||||
});
|
||||
}
|
||||
|
||||
const { legacyNumberId, secondaryId } =
|
||||
envelope.type === EnvelopeType.DOCUMENT
|
||||
? await incrementDocumentId().then(({ documentId, formattedDocumentId }) => ({
|
||||
legacyNumberId: documentId,
|
||||
secondaryId: formattedDocumentId,
|
||||
}))
|
||||
: await incrementTemplateId().then(({ templateId, formattedTemplateId }) => ({
|
||||
legacyNumberId: templateId,
|
||||
secondaryId: formattedTemplateId,
|
||||
}));
|
||||
|
||||
const createdDocumentMeta = await prisma.documentMeta.create({
|
||||
data: {
|
||||
...omit(envelope.documentMeta, ['id']),
|
||||
emailSettings: envelope.documentMeta.emailSettings || undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const duplicatedEnvelope = await prisma.envelope.create({
|
||||
data: {
|
||||
id: prefixedId('envelope'),
|
||||
secondaryId,
|
||||
type: envelope.type,
|
||||
internalVersion: envelope.internalVersion,
|
||||
userId,
|
||||
teamId,
|
||||
title: envelope.title + ' (copy)',
|
||||
documentMetaId: createdDocumentMeta.id,
|
||||
authOptions: envelope.authOptions || undefined,
|
||||
visibility: envelope.visibility,
|
||||
source:
|
||||
envelope.type === EnvelopeType.DOCUMENT ? DocumentSource.DOCUMENT : DocumentSource.TEMPLATE,
|
||||
},
|
||||
include: {
|
||||
recipients: true,
|
||||
documentMeta: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Key = original envelope item ID
|
||||
// Value = duplicated envelope item ID.
|
||||
const oldEnvelopeItemToNewEnvelopeItemIdMap: Record<string, string> = {};
|
||||
|
||||
// Duplicate the envelope items.
|
||||
await Promise.all(
|
||||
envelope.envelopeItems.map(async (envelopeItem) => {
|
||||
const duplicatedDocumentData = await prisma.documentData.create({
|
||||
data: {
|
||||
type: envelopeItem.documentData.type,
|
||||
data: envelopeItem.documentData.initialData,
|
||||
initialData: envelopeItem.documentData.initialData,
|
||||
},
|
||||
});
|
||||
|
||||
const duplicatedEnvelopeItem = await prisma.envelopeItem.create({
|
||||
data: {
|
||||
id: prefixedId('envelope_item'),
|
||||
title: envelopeItem.title,
|
||||
order: envelopeItem.order,
|
||||
envelopeId: duplicatedEnvelope.id,
|
||||
documentDataId: duplicatedDocumentData.id,
|
||||
},
|
||||
});
|
||||
|
||||
oldEnvelopeItemToNewEnvelopeItemIdMap[envelopeItem.id] = duplicatedEnvelopeItem.id;
|
||||
}),
|
||||
);
|
||||
|
||||
for (const recipient of envelope.recipients) {
|
||||
await prisma.recipient.create({
|
||||
data: {
|
||||
envelopeId: duplicatedEnvelope.id,
|
||||
email: recipient.email,
|
||||
name: recipient.name,
|
||||
role: recipient.role,
|
||||
signingOrder: recipient.signingOrder,
|
||||
token: nanoid(),
|
||||
fields: {
|
||||
createMany: {
|
||||
data: recipient.fields.map((field) => ({
|
||||
envelopeId: duplicatedEnvelope.id,
|
||||
envelopeItemId: oldEnvelopeItemToNewEnvelopeItemIdMap[field.envelopeItemId],
|
||||
type: field.type,
|
||||
page: field.page,
|
||||
positionX: field.positionX,
|
||||
positionY: field.positionY,
|
||||
width: field.width,
|
||||
height: field.height,
|
||||
customText: '',
|
||||
inserted: false,
|
||||
fieldMeta: field.fieldMeta as PrismaJson.FieldMeta,
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (duplicatedEnvelope.type === EnvelopeType.DOCUMENT) {
|
||||
const refetchedEnvelope = await prisma.envelope.findFirstOrThrow({
|
||||
where: {
|
||||
id: duplicatedEnvelope.id,
|
||||
},
|
||||
include: {
|
||||
documentMeta: true,
|
||||
recipients: true,
|
||||
},
|
||||
});
|
||||
|
||||
await triggerWebhook({
|
||||
event: WebhookTriggerEvents.DOCUMENT_CREATED,
|
||||
data: ZWebhookDocumentSchema.parse(mapEnvelopeToWebhookDocumentPayload(refetchedEnvelope)),
|
||||
userId: userId,
|
||||
teamId: teamId,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
id: duplicatedEnvelope.id,
|
||||
envelope: duplicatedEnvelope,
|
||||
legacyId: {
|
||||
type: envelope.type,
|
||||
id: legacyNumberId,
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,199 @@
|
||||
import type { Prisma } from '@prisma/client';
|
||||
import type { EnvelopeType } from '@prisma/client';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { TEAM_DOCUMENT_VISIBILITY_MAP } from '../../constants/teams';
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import type { EnvelopeIdOptions } from '../../utils/envelope';
|
||||
import { unsafeBuildEnvelopeIdQuery } from '../../utils/envelope';
|
||||
import { getTeamById } from '../team/get-team';
|
||||
|
||||
export type GetEnvelopeByIdOptions = {
|
||||
id: EnvelopeIdOptions;
|
||||
|
||||
/**
|
||||
* The validated team ID.
|
||||
*/
|
||||
userId: number;
|
||||
|
||||
/**
|
||||
* The unvalidated team ID.
|
||||
*/
|
||||
teamId: number;
|
||||
|
||||
/**
|
||||
* The type of envelope to get.
|
||||
*
|
||||
* Set to null to bypass check.
|
||||
*/
|
||||
type: EnvelopeType | null;
|
||||
};
|
||||
|
||||
export const getEnvelopeById = async ({ id, userId, teamId, type }: GetEnvelopeByIdOptions) => {
|
||||
const { envelopeWhereInput } = await getEnvelopeWhereInput({
|
||||
id,
|
||||
userId,
|
||||
teamId,
|
||||
type,
|
||||
});
|
||||
|
||||
const envelope = await prisma.envelope.findFirst({
|
||||
where: envelopeWhereInput,
|
||||
include: {
|
||||
envelopeItems: {
|
||||
include: {
|
||||
documentData: true,
|
||||
},
|
||||
orderBy: {
|
||||
order: 'asc',
|
||||
},
|
||||
},
|
||||
folder: true,
|
||||
documentMeta: true,
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
recipients: {
|
||||
orderBy: {
|
||||
id: 'asc',
|
||||
},
|
||||
},
|
||||
fields: true,
|
||||
team: {
|
||||
select: {
|
||||
id: true,
|
||||
url: true,
|
||||
},
|
||||
},
|
||||
directLink: {
|
||||
select: {
|
||||
directTemplateRecipientId: true,
|
||||
enabled: true,
|
||||
id: true,
|
||||
token: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!envelope) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Envelope could not be found',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
...envelope,
|
||||
user: {
|
||||
id: envelope.user.id,
|
||||
name: envelope.user.name || '',
|
||||
email: envelope.user.email,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export type GetEnvelopeByIdResponse = Awaited<ReturnType<typeof getEnvelopeById>>;
|
||||
|
||||
export type GetEnvelopeWhereInputOptions = {
|
||||
id: EnvelopeIdOptions;
|
||||
|
||||
/**
|
||||
* The user ID who has been authenticated.
|
||||
*/
|
||||
userId: number;
|
||||
|
||||
/**
|
||||
* The unknown teamId from the request.
|
||||
*/
|
||||
teamId: number;
|
||||
|
||||
/**
|
||||
* The type of envelope to get.
|
||||
*
|
||||
* Set to null to bypass check.
|
||||
*/
|
||||
type: EnvelopeType | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate the where input for a given Prisma envelope query.
|
||||
*
|
||||
* This will return a query that allows a user to get a document if they have valid access to it.
|
||||
*
|
||||
* NOTE: Be extremely careful when modifying this function. Needs at minimum two reviewers to approve any changes.
|
||||
*/
|
||||
export const getEnvelopeWhereInput = async ({
|
||||
id,
|
||||
userId,
|
||||
teamId,
|
||||
type,
|
||||
}: GetEnvelopeWhereInputOptions) => {
|
||||
// Backup validation incase something goes wrong.
|
||||
if (!id.id || !userId || !teamId || type === undefined) {
|
||||
console.error(`[CRTICAL ERROR]: MUST NEVER HAPPEN`);
|
||||
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Envelope ID not found',
|
||||
});
|
||||
}
|
||||
|
||||
// Validate that the user belongs to the team provided.
|
||||
const team = await getTeamById({ teamId, userId });
|
||||
|
||||
const envelopeOrInput: Prisma.EnvelopeWhereInput[] = [
|
||||
// Allow access if they own the document.
|
||||
{
|
||||
userId,
|
||||
},
|
||||
// Or, if they belong to the team that the document is associated with.
|
||||
{
|
||||
visibility: {
|
||||
in: TEAM_DOCUMENT_VISIBILITY_MAP[team.currentTeamRole],
|
||||
},
|
||||
teamId: team.id,
|
||||
},
|
||||
];
|
||||
|
||||
// Allow access to documents sent from the team email.
|
||||
if (team.teamEmail) {
|
||||
envelopeOrInput.push({
|
||||
user: {
|
||||
email: team.teamEmail.email,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
|
||||
// NOTE: DO NOT PUT ANY CODE AFTER THIS POINT.
|
||||
// @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
|
||||
|
||||
const envelopeWhereInput: Prisma.EnvelopeWhereUniqueInput = {
|
||||
...unsafeBuildEnvelopeIdQuery(id, type),
|
||||
OR: envelopeOrInput,
|
||||
};
|
||||
|
||||
// Final backup validation incase something goes wrong.
|
||||
if (
|
||||
!envelopeWhereInput.OR ||
|
||||
envelopeWhereInput.OR.length < 2 ||
|
||||
!userId ||
|
||||
!teamId ||
|
||||
!team.id ||
|
||||
teamId !== team.id
|
||||
) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'Query not valid',
|
||||
});
|
||||
}
|
||||
|
||||
// Do not modify this return directly, all adjustments need to be made prior to the above if statement.
|
||||
return {
|
||||
envelopeWhereInput,
|
||||
team,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,311 @@
|
||||
import { DocumentSigningOrder, DocumentStatus, EnvelopeType, SigningStatus } from '@prisma/client';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import DocumentDataSchema from '@documenso/prisma/generated/zod/modelSchema/DocumentDataSchema';
|
||||
import DocumentMetaSchema from '@documenso/prisma/generated/zod/modelSchema/DocumentMetaSchema';
|
||||
import EnvelopeItemSchema from '@documenso/prisma/generated/zod/modelSchema/EnvelopeItemSchema';
|
||||
import EnvelopeSchema from '@documenso/prisma/generated/zod/modelSchema/EnvelopeSchema';
|
||||
import SignatureSchema from '@documenso/prisma/generated/zod/modelSchema/SignatureSchema';
|
||||
import TeamSchema from '@documenso/prisma/generated/zod/modelSchema/TeamSchema';
|
||||
import UserSchema from '@documenso/prisma/generated/zod/modelSchema/UserSchema';
|
||||
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import type { TDocumentAuthMethods } from '../../types/document-auth';
|
||||
import { ZFieldSchema } from '../../types/field';
|
||||
import { ZRecipientLiteSchema } from '../../types/recipient';
|
||||
import { isRecipientAuthorized } from '../document/is-recipient-authorized';
|
||||
import { getTeamSettings } from '../team/get-team-settings';
|
||||
|
||||
export type GetRecipientEnvelopeByTokenOptions = {
|
||||
token: string;
|
||||
userId?: number;
|
||||
accessAuth?: TDocumentAuthMethods;
|
||||
};
|
||||
|
||||
const ZEnvelopeForSigningResponse = z.object({
|
||||
envelope: EnvelopeSchema.pick({
|
||||
type: true,
|
||||
status: true,
|
||||
id: true,
|
||||
secondaryId: true,
|
||||
internalVersion: true,
|
||||
completedAt: true,
|
||||
deletedAt: true,
|
||||
title: true,
|
||||
authOptions: true,
|
||||
userId: true,
|
||||
teamId: true,
|
||||
}).extend({
|
||||
documentMeta: DocumentMetaSchema.pick({
|
||||
signingOrder: true,
|
||||
distributionMethod: true,
|
||||
timezone: true,
|
||||
dateFormat: true,
|
||||
redirectUrl: true,
|
||||
typedSignatureEnabled: true,
|
||||
uploadSignatureEnabled: true,
|
||||
drawSignatureEnabled: true,
|
||||
allowDictateNextSigner: true,
|
||||
language: true,
|
||||
}),
|
||||
recipients: ZRecipientLiteSchema.pick({
|
||||
id: true,
|
||||
role: true,
|
||||
signingStatus: true,
|
||||
email: true,
|
||||
name: true,
|
||||
documentDeletedAt: true,
|
||||
expired: true,
|
||||
signedAt: true,
|
||||
authOptions: true,
|
||||
signingOrder: true,
|
||||
rejectionReason: true,
|
||||
})
|
||||
.extend({
|
||||
fields: ZFieldSchema.omit({
|
||||
documentId: true,
|
||||
templateId: true,
|
||||
}).array(),
|
||||
})
|
||||
.array(),
|
||||
|
||||
envelopeItems: EnvelopeItemSchema.pick({
|
||||
id: true,
|
||||
title: true,
|
||||
documentDataId: true,
|
||||
order: true,
|
||||
})
|
||||
.extend({
|
||||
documentData: DocumentDataSchema.pick({
|
||||
type: true,
|
||||
id: true,
|
||||
data: true,
|
||||
initialData: true,
|
||||
}),
|
||||
})
|
||||
.array(),
|
||||
|
||||
team: TeamSchema.pick({
|
||||
id: true,
|
||||
name: true,
|
||||
}),
|
||||
user: UserSchema.pick({
|
||||
name: true,
|
||||
email: true,
|
||||
}),
|
||||
}),
|
||||
|
||||
/**
|
||||
* The recipient that is currently signing.
|
||||
*/
|
||||
recipient: ZRecipientLiteSchema.pick({
|
||||
id: true,
|
||||
role: true,
|
||||
envelopeId: true,
|
||||
readStatus: true,
|
||||
sendStatus: true,
|
||||
signingStatus: true,
|
||||
email: true,
|
||||
name: true,
|
||||
documentDeletedAt: true,
|
||||
expired: true,
|
||||
signedAt: true,
|
||||
authOptions: true,
|
||||
token: true,
|
||||
signingOrder: true,
|
||||
rejectionReason: true,
|
||||
}).extend({
|
||||
fields: ZFieldSchema.omit({
|
||||
documentId: true,
|
||||
templateId: true,
|
||||
})
|
||||
.extend({
|
||||
signature: SignatureSchema.nullish(),
|
||||
})
|
||||
.array(),
|
||||
}),
|
||||
recipientSignature: SignatureSchema.pick({
|
||||
signatureImageAsBase64: true,
|
||||
typedSignature: true,
|
||||
}).nullable(),
|
||||
|
||||
isCompleted: z.boolean(),
|
||||
isRejected: z.boolean(),
|
||||
isRecipientsTurn: z.boolean(),
|
||||
|
||||
sender: z.object({
|
||||
email: z.string(),
|
||||
name: z.string(),
|
||||
}),
|
||||
|
||||
settings: z.object({
|
||||
includeSenderDetails: z.boolean(),
|
||||
brandingEnabled: z.boolean(),
|
||||
brandingLogo: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
export type EnvelopeForSigningResponse = z.infer<typeof ZEnvelopeForSigningResponse>;
|
||||
|
||||
/**
|
||||
* Get all the values and details for an envelope that a recipient requires
|
||||
* to sign an envelope.
|
||||
*
|
||||
* Do not overexpose any information that the recipient should not have.
|
||||
*/
|
||||
export const getEnvelopeForRecipientSigning = async ({
|
||||
token,
|
||||
userId,
|
||||
accessAuth,
|
||||
}: GetRecipientEnvelopeByTokenOptions): Promise<EnvelopeForSigningResponse> => {
|
||||
if (!token) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Missing token',
|
||||
});
|
||||
}
|
||||
|
||||
const envelope = await prisma.envelope.findFirst({
|
||||
where: {
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
status: {
|
||||
not: DocumentStatus.DRAFT,
|
||||
},
|
||||
recipients: {
|
||||
some: {
|
||||
token,
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
documentMeta: true,
|
||||
recipients: {
|
||||
include: {
|
||||
fields: {
|
||||
include: {
|
||||
signature: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
signingOrder: 'asc',
|
||||
},
|
||||
},
|
||||
envelopeItems: {
|
||||
include: {
|
||||
documentData: true,
|
||||
},
|
||||
},
|
||||
team: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
teamEmail: true,
|
||||
teamGlobalSettings: {
|
||||
select: {
|
||||
includeSigningCertificate: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const recipient = (envelope?.recipients || []).find((r) => r.token === token);
|
||||
|
||||
if (!envelope || !recipient) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Envelope not found',
|
||||
});
|
||||
}
|
||||
|
||||
if (envelope.envelopeItems.length === 0) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Envelope has no items',
|
||||
});
|
||||
}
|
||||
|
||||
const documentAccessValid = await isRecipientAuthorized({
|
||||
type: 'ACCESS',
|
||||
documentAuthOptions: envelope.authOptions,
|
||||
recipient,
|
||||
userId,
|
||||
authOptions: accessAuth,
|
||||
});
|
||||
|
||||
if (!documentAccessValid) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'Invalid access values',
|
||||
});
|
||||
}
|
||||
|
||||
const settings = await getTeamSettings({ teamId: envelope.teamId });
|
||||
|
||||
// Get the signature if they have put it in already.
|
||||
const recipientSignature = await prisma.signature.findFirst({
|
||||
where: {
|
||||
field: {
|
||||
recipientId: recipient.id,
|
||||
envelopeId: envelope.id,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
recipientId: true,
|
||||
signatureImageAsBase64: true,
|
||||
typedSignature: true,
|
||||
},
|
||||
});
|
||||
|
||||
let isRecipientsTurn = true;
|
||||
|
||||
const currentRecipientIndex = envelope.recipients.findIndex((r) => r.token === token);
|
||||
|
||||
if (
|
||||
envelope.documentMeta.signingOrder === DocumentSigningOrder.SEQUENTIAL &&
|
||||
currentRecipientIndex !== -1
|
||||
) {
|
||||
for (let i = 0; i < currentRecipientIndex; i++) {
|
||||
if (envelope.recipients[i].signingStatus !== SigningStatus.SIGNED) {
|
||||
isRecipientsTurn = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const sender = settings.includeSenderDetails
|
||||
? {
|
||||
email: envelope.user.email,
|
||||
name: envelope.user.name || '',
|
||||
}
|
||||
: {
|
||||
email: envelope.team.teamEmail?.email || '',
|
||||
name: envelope.team.name || '',
|
||||
};
|
||||
|
||||
return ZEnvelopeForSigningResponse.parse({
|
||||
envelope,
|
||||
recipient,
|
||||
recipientSignature,
|
||||
isRecipientsTurn,
|
||||
isCompleted:
|
||||
recipient.signingStatus === SigningStatus.SIGNED ||
|
||||
envelope.status === DocumentStatus.COMPLETED,
|
||||
isRejected:
|
||||
recipient.signingStatus === SigningStatus.REJECTED ||
|
||||
envelope.status === DocumentStatus.REJECTED,
|
||||
sender,
|
||||
settings: {
|
||||
includeSenderDetails: settings.includeSenderDetails,
|
||||
brandingEnabled: settings.brandingEnabled,
|
||||
brandingLogo: settings.brandingLogo,
|
||||
},
|
||||
} satisfies EnvelopeForSigningResponse);
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
import { DocumentStatus, EnvelopeType } from '@prisma/client';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
|
||||
export const getEnvelopeRequiredAccessData = async ({ token }: { token: string }) => {
|
||||
const envelope = await prisma.envelope.findFirst({
|
||||
where: {
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
status: {
|
||||
not: DocumentStatus.DRAFT,
|
||||
},
|
||||
recipients: {
|
||||
some: {
|
||||
token,
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
recipients: {
|
||||
where: {
|
||||
token,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!envelope) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Envelope not found',
|
||||
});
|
||||
}
|
||||
|
||||
const recipient = envelope.recipients.find((r) => r.token === token);
|
||||
|
||||
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;
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { mapDocumentIdToSecondaryId, mapTemplateIdToSecondaryId } from '../../utils/envelope';
|
||||
|
||||
export const incrementDocumentId = async () => {
|
||||
const documentIdCounter = await prisma.counter.update({
|
||||
where: {
|
||||
id: 'document',
|
||||
},
|
||||
data: {
|
||||
value: {
|
||||
increment: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
documentId: documentIdCounter.value,
|
||||
formattedDocumentId: mapDocumentIdToSecondaryId(documentIdCounter.value),
|
||||
};
|
||||
};
|
||||
|
||||
export const incrementTemplateId = async () => {
|
||||
const templateIdCounter = await prisma.counter.update({
|
||||
where: {
|
||||
id: 'template',
|
||||
},
|
||||
data: {
|
||||
value: {
|
||||
increment: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
templateId: templateIdCounter.value,
|
||||
formattedTemplateId: mapTemplateIdToSecondaryId(templateIdCounter.value),
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,344 @@
|
||||
import type { DocumentMeta, DocumentVisibility, Prisma, TemplateType } 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';
|
||||
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||
import type { CreateDocumentAuditLogDataResponse } from '@documenso/lib/utils/document-audit-logs';
|
||||
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
|
||||
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 { createDocumentAuthOptions, extractDocumentAuthMethods } from '../../utils/document-auth';
|
||||
import type { EnvelopeIdOptions } from '../../utils/envelope';
|
||||
import { buildTeamWhereQuery, canAccessTeamDocument } from '../../utils/teams';
|
||||
import { getEnvelopeWhereInput } from './get-envelope-by-id';
|
||||
|
||||
export type UpdateEnvelopeOptions = {
|
||||
userId: number;
|
||||
teamId: number;
|
||||
id: EnvelopeIdOptions;
|
||||
data?: {
|
||||
title?: string;
|
||||
folderId?: string | null;
|
||||
externalId?: string | null;
|
||||
visibility?: DocumentVisibility;
|
||||
globalAccessAuth?: TDocumentAccessAuthTypes[];
|
||||
globalActionAuth?: TDocumentActionAuthTypes[];
|
||||
publicTitle?: string;
|
||||
publicDescription?: string;
|
||||
templateType?: TemplateType;
|
||||
useLegacyFieldInsertion?: boolean;
|
||||
};
|
||||
meta?: Partial<Omit<DocumentMeta, 'id'>>;
|
||||
requestMetadata: ApiRequestMetadata;
|
||||
};
|
||||
|
||||
export const updateEnvelope = async ({
|
||||
userId,
|
||||
teamId,
|
||||
id,
|
||||
data = {},
|
||||
meta = {},
|
||||
requestMetadata,
|
||||
}: UpdateEnvelopeOptions) => {
|
||||
const { envelopeWhereInput, team } = await getEnvelopeWhereInput({
|
||||
id,
|
||||
type: null, // Allow updating both documents and templates.
|
||||
userId,
|
||||
teamId,
|
||||
});
|
||||
|
||||
const envelope = await prisma.envelope.findFirst({
|
||||
where: envelopeWhereInput,
|
||||
include: {
|
||||
documentMeta: true,
|
||||
team: {
|
||||
select: {
|
||||
organisationId: true,
|
||||
organisation: {
|
||||
select: {
|
||||
organisationClaim: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!envelope) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Envelope not found',
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
envelope.type !== EnvelopeType.TEMPLATE &&
|
||||
(data.publicTitle || data.publicDescription || data.templateType)
|
||||
) {
|
||||
throw new AppError(AppErrorCode.INVALID_BODY, {
|
||||
message: 'You cannot update the template fields for document type envelopes',
|
||||
});
|
||||
}
|
||||
|
||||
// If no data just return the document since this function is normally chained after a meta update.
|
||||
if (Object.values(data).length === 0 && Object.keys(meta).length === 0) {
|
||||
return envelope;
|
||||
}
|
||||
|
||||
const isEnvelopeOwner = envelope.userId === userId;
|
||||
|
||||
// Validate whether the new visibility setting is allowed for the current user.
|
||||
if (
|
||||
!isEnvelopeOwner &&
|
||||
data?.visibility &&
|
||||
!canAccessTeamDocument(team.currentTeamRole, data.visibility)
|
||||
) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'You do not have permission to update the envelope visibility',
|
||||
});
|
||||
}
|
||||
|
||||
const { documentAuthOption } = extractDocumentAuthMethods({
|
||||
documentAuth: envelope.authOptions,
|
||||
});
|
||||
|
||||
const documentGlobalAccessAuth = documentAuthOption?.globalAccessAuth ?? null;
|
||||
const documentGlobalActionAuth = documentAuthOption?.globalActionAuth ?? null;
|
||||
|
||||
// If the new global auth values aren't passed in, fallback to the current document values.
|
||||
const newGlobalAccessAuth =
|
||||
data?.globalAccessAuth === undefined ? documentGlobalAccessAuth : data.globalAccessAuth;
|
||||
const newGlobalActionAuth =
|
||||
data?.globalActionAuth === undefined ? documentGlobalActionAuth : data.globalActionAuth;
|
||||
|
||||
// Check if user has permission to set the global action auth.
|
||||
if (newGlobalActionAuth.length > 0 && !envelope.team.organisation.organisationClaim.flags.cfr21) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'You do not have permission to set the action auth',
|
||||
});
|
||||
}
|
||||
|
||||
const authOptions = createDocumentAuthOptions({
|
||||
globalAccessAuth: newGlobalAccessAuth,
|
||||
globalActionAuth: newGlobalActionAuth,
|
||||
});
|
||||
|
||||
const emailId = meta.emailId;
|
||||
|
||||
// Validate the emailId belongs to the organisation.
|
||||
if (emailId) {
|
||||
const email = await prisma.organisationEmail.findFirst({
|
||||
where: {
|
||||
id: emailId,
|
||||
organisationId: envelope.team.organisationId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!email) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Email not found',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let folderUpdateQuery: Prisma.FolderUpdateOneWithoutEnvelopesNestedInput | undefined = undefined;
|
||||
|
||||
// Validate folder ID.
|
||||
if (data.folderId) {
|
||||
const folder = await prisma.folder.findFirst({
|
||||
where: {
|
||||
id: data.folderId,
|
||||
team: buildTeamWhereQuery({
|
||||
teamId,
|
||||
userId,
|
||||
}),
|
||||
type: envelope.type === EnvelopeType.TEMPLATE ? FolderType.TEMPLATE : FolderType.DOCUMENT,
|
||||
visibility: {
|
||||
in: TEAM_DOCUMENT_VISIBILITY_MAP[team.currentTeamRole],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!folder) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Folder not found',
|
||||
});
|
||||
}
|
||||
|
||||
folderUpdateQuery = {
|
||||
connect: {
|
||||
id: data.folderId,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Move to root folder if folderId is null.
|
||||
if (data.folderId === null) {
|
||||
folderUpdateQuery = {
|
||||
disconnect: true,
|
||||
};
|
||||
}
|
||||
|
||||
const isTitleSame = data.title === undefined || data.title === envelope.title;
|
||||
const isExternalIdSame = data.externalId === undefined || data.externalId === envelope.externalId;
|
||||
const isGlobalAccessSame =
|
||||
documentGlobalAccessAuth === undefined ||
|
||||
isDeepEqual(documentGlobalAccessAuth, newGlobalAccessAuth);
|
||||
const isGlobalActionSame =
|
||||
documentGlobalActionAuth === undefined ||
|
||||
isDeepEqual(documentGlobalActionAuth, newGlobalActionAuth);
|
||||
const isDocumentVisibilitySame =
|
||||
data.visibility === undefined || data.visibility === envelope.visibility;
|
||||
const isFolderSame = data.folderId === undefined || data.folderId === envelope.folderId;
|
||||
const isTemplateTypeSame =
|
||||
data.templateType === undefined || data.templateType === envelope.templateType;
|
||||
const isPublicDescriptionSame =
|
||||
data.publicDescription === undefined || data.publicDescription === envelope.publicDescription;
|
||||
const isPublicTitleSame =
|
||||
data.publicTitle === undefined || data.publicTitle === envelope.publicTitle;
|
||||
|
||||
const auditLogs: CreateDocumentAuditLogDataResponse[] = [];
|
||||
|
||||
if (!isTitleSame && envelope.status !== DocumentStatus.DRAFT) {
|
||||
throw new AppError(AppErrorCode.INVALID_BODY, {
|
||||
message: 'You cannot update the title if the envelope has been sent',
|
||||
});
|
||||
}
|
||||
|
||||
if (!isTitleSame) {
|
||||
auditLogs.push(
|
||||
createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_TITLE_UPDATED,
|
||||
envelopeId: envelope.id,
|
||||
metadata: requestMetadata,
|
||||
data: {
|
||||
from: envelope.title,
|
||||
to: data.title || '',
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (!isExternalIdSame) {
|
||||
auditLogs.push(
|
||||
createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_EXTERNAL_ID_UPDATED,
|
||||
envelopeId: envelope.id,
|
||||
metadata: requestMetadata,
|
||||
data: {
|
||||
from: envelope.externalId,
|
||||
to: data.externalId || '',
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (!isGlobalAccessSame) {
|
||||
auditLogs.push(
|
||||
createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_GLOBAL_AUTH_ACCESS_UPDATED,
|
||||
envelopeId: envelope.id,
|
||||
metadata: requestMetadata,
|
||||
data: {
|
||||
from: documentGlobalAccessAuth,
|
||||
to: newGlobalAccessAuth,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (!isGlobalActionSame) {
|
||||
auditLogs.push(
|
||||
createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_GLOBAL_AUTH_ACTION_UPDATED,
|
||||
envelopeId: envelope.id,
|
||||
metadata: requestMetadata,
|
||||
data: {
|
||||
from: documentGlobalActionAuth,
|
||||
to: newGlobalActionAuth,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (!isDocumentVisibilitySame) {
|
||||
auditLogs.push(
|
||||
createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_VISIBILITY_UPDATED,
|
||||
envelopeId: envelope.id,
|
||||
metadata: requestMetadata,
|
||||
data: {
|
||||
from: envelope.visibility,
|
||||
to: data.visibility || '',
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Todo: Decide if we want to log moving the document around.
|
||||
// if (!isFolderSame) {
|
||||
// auditLogs.push(
|
||||
// createDocumentAuditLogData({
|
||||
// type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FOLDER_UPDATED,
|
||||
// envelopeId: envelope.id,
|
||||
// metadata: requestMetadata,
|
||||
// data: {
|
||||
// from: envelope.folderId,
|
||||
// to: data.folderId || '',
|
||||
// },
|
||||
// }),
|
||||
// );
|
||||
// }
|
||||
|
||||
// Todo: Determine if changes are made
|
||||
// Commented out since we didn't detect the changes to sequence.
|
||||
// const isMetaSame = isDeepEqual(envelope.documentMeta, meta);
|
||||
// Early return if nothing is required.
|
||||
// if (
|
||||
// auditLogs.length === 0 &&
|
||||
// data.useLegacyFieldInsertion === undefined &&
|
||||
// isFolderSame &&
|
||||
// isTemplateTypeSame &&
|
||||
// isPublicDescriptionSame &&
|
||||
// isPublicTitleSame
|
||||
// ) {
|
||||
// return envelope;
|
||||
// }
|
||||
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
const updatedEnvelope = await tx.envelope.update({
|
||||
where: {
|
||||
id: envelope.id,
|
||||
},
|
||||
data: {
|
||||
title: data.title,
|
||||
externalId: data.externalId,
|
||||
visibility: data.visibility,
|
||||
templateType: data.templateType,
|
||||
publicDescription: data.publicDescription,
|
||||
publicTitle: data.publicTitle,
|
||||
useLegacyFieldInsertion: data.useLegacyFieldInsertion,
|
||||
authOptions,
|
||||
folder: folderUpdateQuery,
|
||||
documentMeta: {
|
||||
update: {
|
||||
...meta,
|
||||
emailSettings: meta?.emailSettings || undefined,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (envelope.type === EnvelopeType.DOCUMENT) {
|
||||
await tx.documentAuditLog.createMany({
|
||||
data: auditLogs,
|
||||
});
|
||||
}
|
||||
|
||||
return updatedEnvelope;
|
||||
});
|
||||
};
|
||||
@@ -1,126 +0,0 @@
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
||||
import type { TFieldAndMeta } from '@documenso/lib/types/field-meta';
|
||||
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { canRecipientFieldsBeModified } from '../../utils/recipients';
|
||||
import { getDocumentWhereInput } from '../document/get-document-by-id';
|
||||
|
||||
export interface CreateDocumentFieldsOptions {
|
||||
userId: number;
|
||||
teamId: number;
|
||||
documentId: number;
|
||||
fields: (TFieldAndMeta & {
|
||||
recipientId: number;
|
||||
pageNumber: number;
|
||||
pageX: number;
|
||||
pageY: number;
|
||||
width: number;
|
||||
height: number;
|
||||
})[];
|
||||
requestMetadata: ApiRequestMetadata;
|
||||
}
|
||||
|
||||
export const createDocumentFields = async ({
|
||||
userId,
|
||||
teamId,
|
||||
documentId,
|
||||
fields,
|
||||
requestMetadata,
|
||||
}: CreateDocumentFieldsOptions) => {
|
||||
const { documentWhereInput } = await getDocumentWhereInput({
|
||||
documentId,
|
||||
userId,
|
||||
teamId,
|
||||
});
|
||||
|
||||
const document = await prisma.document.findFirst({
|
||||
where: documentWhereInput,
|
||||
include: {
|
||||
recipients: true,
|
||||
fields: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!document) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Document not found',
|
||||
});
|
||||
}
|
||||
|
||||
if (document.completedAt) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'Document already complete',
|
||||
});
|
||||
}
|
||||
|
||||
// Field validation.
|
||||
const validatedFields = fields.map((field) => {
|
||||
const recipient = document.recipients.find((recipient) => recipient.id === field.recipientId);
|
||||
|
||||
// Each field MUST have a recipient associated with it.
|
||||
if (!recipient) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: `Recipient ${field.recipientId} not found`,
|
||||
});
|
||||
}
|
||||
|
||||
// Check whether the recipient associated with the field can have new fields created.
|
||||
if (!canRecipientFieldsBeModified(recipient, document.fields)) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message:
|
||||
'Recipient type cannot have fields, or they have already interacted with the document.',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
...field,
|
||||
recipientEmail: recipient.email,
|
||||
};
|
||||
});
|
||||
|
||||
const createdFields = await prisma.$transaction(async (tx) => {
|
||||
return await Promise.all(
|
||||
validatedFields.map(async (field) => {
|
||||
const createdField = await tx.field.create({
|
||||
data: {
|
||||
type: field.type,
|
||||
page: field.pageNumber,
|
||||
positionX: field.pageX,
|
||||
positionY: field.pageY,
|
||||
width: field.width,
|
||||
height: field.height,
|
||||
customText: '',
|
||||
inserted: false,
|
||||
fieldMeta: field.fieldMeta,
|
||||
documentId,
|
||||
recipientId: field.recipientId,
|
||||
},
|
||||
});
|
||||
|
||||
// Handle field created audit log.
|
||||
await tx.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_CREATED,
|
||||
documentId,
|
||||
metadata: requestMetadata,
|
||||
data: {
|
||||
fieldId: createdField.secondaryId,
|
||||
fieldRecipientEmail: field.recipientEmail,
|
||||
fieldRecipientId: createdField.recipientId,
|
||||
fieldType: createdField.type,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
return createdField;
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
return {
|
||||
fields: createdFields,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,168 @@
|
||||
import { EnvelopeType } from '@prisma/client';
|
||||
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
||||
import type { TFieldAndMeta } from '@documenso/lib/types/field-meta';
|
||||
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
|
||||
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 CreateEnvelopeFieldsOptions {
|
||||
userId: number;
|
||||
teamId: number;
|
||||
id: EnvelopeIdOptions;
|
||||
|
||||
fields: (TFieldAndMeta & {
|
||||
/**
|
||||
* The ID of the item to insert the fields into.
|
||||
*
|
||||
* If blank, the first item will be used.
|
||||
*/
|
||||
envelopeItemId?: string;
|
||||
|
||||
recipientId: number;
|
||||
pageNumber: number;
|
||||
pageX: number;
|
||||
pageY: number;
|
||||
width: number;
|
||||
height: number;
|
||||
})[];
|
||||
requestMetadata: ApiRequestMetadata;
|
||||
}
|
||||
|
||||
export const createEnvelopeFields = async ({
|
||||
userId,
|
||||
teamId,
|
||||
id,
|
||||
fields,
|
||||
requestMetadata,
|
||||
}: CreateEnvelopeFieldsOptions) => {
|
||||
const { envelopeWhereInput } = await getEnvelopeWhereInput({
|
||||
id,
|
||||
type: null, // Null to allow any type of envelope.
|
||||
userId,
|
||||
teamId,
|
||||
});
|
||||
|
||||
const envelope = await prisma.envelope.findFirst({
|
||||
where: envelopeWhereInput,
|
||||
include: {
|
||||
recipients: true,
|
||||
fields: true,
|
||||
envelopeItems: {
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!envelope) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Envelope not found',
|
||||
});
|
||||
}
|
||||
|
||||
if (envelope.type === EnvelopeType.DOCUMENT && envelope.completedAt) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'Envelope already complete',
|
||||
});
|
||||
}
|
||||
|
||||
const firstEnvelopeItem = envelope.envelopeItems[0];
|
||||
|
||||
if (!firstEnvelopeItem) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Envelope item not found',
|
||||
});
|
||||
}
|
||||
|
||||
// Field validation.
|
||||
const validatedFields = fields.map((field) => {
|
||||
const recipient = envelope.recipients.find((recipient) => recipient.id === field.recipientId);
|
||||
|
||||
// The item to attach the fields to MUST belong to the document.
|
||||
if (
|
||||
field.envelopeItemId &&
|
||||
!envelope.envelopeItems.find((envelopeItem) => envelopeItem.id === field.envelopeItemId)
|
||||
) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'Item to attach fields to must belong to the document',
|
||||
});
|
||||
}
|
||||
|
||||
// Each field MUST have a recipient associated with it.
|
||||
if (!recipient) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: `Recipient ${field.recipientId} not found`,
|
||||
});
|
||||
}
|
||||
|
||||
// Check whether the recipient associated with the field can have new fields created.
|
||||
if (!canRecipientFieldsBeModified(recipient, envelope.fields)) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message:
|
||||
'Recipient type cannot have fields, or they have already interacted with the document.',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
...field,
|
||||
envelopeItemId: field.envelopeItemId || firstEnvelopeItem.id, // Fallback to first envelope item if no envelope item ID is provided.
|
||||
recipientEmail: recipient.email,
|
||||
};
|
||||
});
|
||||
|
||||
const createdFields = await prisma.$transaction(async (tx) => {
|
||||
const newlyCreatedFields = await tx.field.createManyAndReturn({
|
||||
data: validatedFields.map((field) => ({
|
||||
type: field.type,
|
||||
page: field.pageNumber,
|
||||
positionX: field.pageX,
|
||||
positionY: field.pageY,
|
||||
width: field.width,
|
||||
height: field.height,
|
||||
customText: '',
|
||||
inserted: false,
|
||||
fieldMeta: field.fieldMeta,
|
||||
envelopeId: envelope.id,
|
||||
envelopeItemId: field.envelopeItemId,
|
||||
recipientId: field.recipientId,
|
||||
})),
|
||||
});
|
||||
|
||||
// Handle field created audit log.
|
||||
if (envelope.type === EnvelopeType.DOCUMENT) {
|
||||
await tx.documentAuditLog.createMany({
|
||||
data: newlyCreatedFields.map((createdField) => {
|
||||
const recipient = validatedFields.find(
|
||||
(field) => field.recipientId === createdField.recipientId,
|
||||
);
|
||||
|
||||
return createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_CREATED,
|
||||
envelopeId: envelope.id,
|
||||
metadata: requestMetadata,
|
||||
data: {
|
||||
fieldId: createdField.secondaryId,
|
||||
fieldRecipientEmail: recipient?.recipientEmail || '',
|
||||
fieldRecipientId: createdField.recipientId,
|
||||
fieldType: createdField.type,
|
||||
},
|
||||
});
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
return newlyCreatedFields;
|
||||
});
|
||||
|
||||
return {
|
||||
fields: createdFields.map((field) => mapFieldToLegacyField(field, envelope)),
|
||||
};
|
||||
};
|
||||
@@ -1,136 +0,0 @@
|
||||
import type { FieldType } from '@prisma/client';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import {
|
||||
ZCheckboxFieldMeta,
|
||||
ZDropdownFieldMeta,
|
||||
ZNumberFieldMeta,
|
||||
ZRadioFieldMeta,
|
||||
ZTextFieldMeta,
|
||||
} from '../../types/field-meta';
|
||||
import type { TFieldMetaSchema as FieldMeta } from '../../types/field-meta';
|
||||
import type { RequestMetadata } from '../../universal/extract-request-metadata';
|
||||
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
|
||||
import { getDocumentWhereInput } from '../document/get-document-by-id';
|
||||
|
||||
export type CreateFieldOptions = {
|
||||
documentId: number;
|
||||
userId: number;
|
||||
teamId: number;
|
||||
recipientId: number;
|
||||
type: FieldType;
|
||||
pageNumber: number;
|
||||
pageX: number;
|
||||
pageY: number;
|
||||
pageWidth: number;
|
||||
pageHeight: number;
|
||||
fieldMeta?: FieldMeta;
|
||||
requestMetadata?: RequestMetadata;
|
||||
};
|
||||
|
||||
export const createField = async ({
|
||||
documentId,
|
||||
userId,
|
||||
teamId,
|
||||
recipientId,
|
||||
type,
|
||||
pageNumber,
|
||||
pageX,
|
||||
pageY,
|
||||
pageWidth,
|
||||
pageHeight,
|
||||
fieldMeta,
|
||||
requestMetadata,
|
||||
}: CreateFieldOptions) => {
|
||||
const { documentWhereInput, team } = await getDocumentWhereInput({
|
||||
documentId,
|
||||
userId,
|
||||
teamId,
|
||||
});
|
||||
|
||||
const document = await prisma.document.findFirst({
|
||||
where: documentWhereInput,
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!document) {
|
||||
throw new Error('Document not found');
|
||||
}
|
||||
|
||||
const advancedField = ['NUMBER', 'RADIO', 'CHECKBOX', 'DROPDOWN', 'TEXT'].includes(type);
|
||||
|
||||
if (advancedField && !fieldMeta) {
|
||||
throw new Error(
|
||||
'Field meta is required for this type of field. Please provide the appropriate field meta object.',
|
||||
);
|
||||
}
|
||||
|
||||
if (fieldMeta && fieldMeta.type.toLowerCase() !== String(type).toLowerCase()) {
|
||||
throw new Error('Field meta type does not match the field type');
|
||||
}
|
||||
|
||||
const result = match(type)
|
||||
.with('RADIO', () => ZRadioFieldMeta.safeParse(fieldMeta))
|
||||
.with('CHECKBOX', () => ZCheckboxFieldMeta.safeParse(fieldMeta))
|
||||
.with('DROPDOWN', () => ZDropdownFieldMeta.safeParse(fieldMeta))
|
||||
.with('NUMBER', () => ZNumberFieldMeta.safeParse(fieldMeta))
|
||||
.with('TEXT', () => ZTextFieldMeta.safeParse(fieldMeta))
|
||||
.with('SIGNATURE', 'INITIALS', 'DATE', 'EMAIL', 'NAME', () => ({
|
||||
success: true,
|
||||
data: {},
|
||||
}))
|
||||
.with('FREE_SIGNATURE', () => ({
|
||||
success: false,
|
||||
error: 'FREE_SIGNATURE is not supported',
|
||||
data: {},
|
||||
}))
|
||||
.exhaustive();
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error('Field meta parsing failed');
|
||||
}
|
||||
|
||||
const field = await prisma.field.create({
|
||||
data: {
|
||||
documentId,
|
||||
recipientId,
|
||||
type,
|
||||
page: pageNumber,
|
||||
positionX: pageX,
|
||||
positionY: pageY,
|
||||
width: pageWidth,
|
||||
height: pageHeight,
|
||||
customText: '',
|
||||
inserted: false,
|
||||
fieldMeta: result.data,
|
||||
},
|
||||
include: {
|
||||
recipient: true,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: 'FIELD_CREATED',
|
||||
documentId,
|
||||
user: {
|
||||
id: team.id,
|
||||
email: team.name,
|
||||
name: '',
|
||||
},
|
||||
data: {
|
||||
fieldId: field.secondaryId,
|
||||
fieldRecipientEmail: field.recipient?.email ?? '',
|
||||
fieldRecipientId: recipientId,
|
||||
fieldType: field.type,
|
||||
},
|
||||
requestMetadata,
|
||||
}),
|
||||
});
|
||||
|
||||
return field;
|
||||
};
|
||||
@@ -1,101 +0,0 @@
|
||||
import 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 { canRecipientFieldsBeModified } from '../../utils/recipients';
|
||||
import { buildTeamWhereQuery } from '../../utils/teams';
|
||||
|
||||
export interface CreateTemplateFieldsOptions {
|
||||
userId: number;
|
||||
teamId: number;
|
||||
templateId: number;
|
||||
fields: {
|
||||
recipientId: number;
|
||||
type: FieldType;
|
||||
pageNumber: number;
|
||||
pageX: number;
|
||||
pageY: number;
|
||||
width: number;
|
||||
height: number;
|
||||
fieldMeta?: TFieldMetaSchema;
|
||||
}[];
|
||||
}
|
||||
|
||||
export const createTemplateFields = async ({
|
||||
userId,
|
||||
teamId,
|
||||
templateId,
|
||||
fields,
|
||||
}: CreateTemplateFieldsOptions) => {
|
||||
const template = await prisma.template.findFirst({
|
||||
where: {
|
||||
id: templateId,
|
||||
team: buildTeamWhereQuery({ teamId, userId }),
|
||||
},
|
||||
include: {
|
||||
recipients: true,
|
||||
fields: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!template) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'template not found',
|
||||
});
|
||||
}
|
||||
|
||||
// Field validation.
|
||||
const validatedFields = fields.map((field) => {
|
||||
const recipient = template.recipients.find((recipient) => recipient.id === field.recipientId);
|
||||
|
||||
// Each field MUST have a recipient associated with it.
|
||||
if (!recipient) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: `Recipient ${field.recipientId} not found`,
|
||||
});
|
||||
}
|
||||
|
||||
// Check whether the recipient associated with the field can have new fields created.
|
||||
if (!canRecipientFieldsBeModified(recipient, template.fields)) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message:
|
||||
'Recipient type cannot have fields, or they have already interacted with the template.',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
...field,
|
||||
recipientEmail: recipient.email,
|
||||
};
|
||||
});
|
||||
|
||||
const createdFields = await prisma.$transaction(async (tx) => {
|
||||
return await Promise.all(
|
||||
validatedFields.map(async (field) => {
|
||||
const createdField = await tx.field.create({
|
||||
data: {
|
||||
type: field.type,
|
||||
page: field.pageNumber,
|
||||
positionX: field.pageX,
|
||||
positionY: field.pageY,
|
||||
width: field.width,
|
||||
height: field.height,
|
||||
customText: '',
|
||||
inserted: false,
|
||||
fieldMeta: field.fieldMeta,
|
||||
templateId,
|
||||
recipientId: field.recipientId,
|
||||
},
|
||||
});
|
||||
|
||||
return createdField;
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
return {
|
||||
fields: createdFields,
|
||||
};
|
||||
};
|
||||
@@ -1,3 +1,5 @@
|
||||
import { EnvelopeType } from '@prisma/client';
|
||||
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
||||
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
|
||||
@@ -5,7 +7,7 @@ import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { canRecipientFieldsBeModified } from '../../utils/recipients';
|
||||
import { getDocumentWhereInput } from '../document/get-document-by-id';
|
||||
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
|
||||
|
||||
export interface DeleteDocumentFieldOptions {
|
||||
userId: number;
|
||||
@@ -19,7 +21,8 @@ export const deleteDocumentField = async ({
|
||||
teamId,
|
||||
fieldId,
|
||||
requestMetadata,
|
||||
}: DeleteDocumentFieldOptions): Promise<void> => {
|
||||
}: DeleteDocumentFieldOptions) => {
|
||||
// Unauthenticated check, we do the real check later.
|
||||
const field = await prisma.field.findFirst({
|
||||
where: {
|
||||
id: fieldId,
|
||||
@@ -32,22 +35,18 @@ export const deleteDocumentField = async ({
|
||||
});
|
||||
}
|
||||
|
||||
const documentId = field.documentId;
|
||||
|
||||
if (!documentId) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Field does not belong to a document. Use delete template field instead.',
|
||||
});
|
||||
}
|
||||
|
||||
const { documentWhereInput } = await getDocumentWhereInput({
|
||||
documentId,
|
||||
const { envelopeWhereInput } = await getEnvelopeWhereInput({
|
||||
id: {
|
||||
type: 'envelopeId',
|
||||
id: field.envelopeId,
|
||||
},
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
userId,
|
||||
teamId,
|
||||
});
|
||||
|
||||
const document = await prisma.document.findFirst({
|
||||
where: documentWhereInput,
|
||||
const envelope = await prisma.envelope.findUnique({
|
||||
where: envelopeWhereInput,
|
||||
include: {
|
||||
recipients: {
|
||||
where: {
|
||||
@@ -60,19 +59,19 @@ export const deleteDocumentField = async ({
|
||||
},
|
||||
});
|
||||
|
||||
if (!document) {
|
||||
if (!envelope) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Document not found',
|
||||
});
|
||||
}
|
||||
|
||||
if (document.completedAt) {
|
||||
if (envelope.completedAt) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'Document already complete',
|
||||
});
|
||||
}
|
||||
|
||||
const recipient = document.recipients.find((recipient) => recipient.id === field.recipientId);
|
||||
const recipient = envelope.recipients.find((recipient) => recipient.id === field.recipientId);
|
||||
|
||||
if (!recipient) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
@@ -87,10 +86,11 @@ export const deleteDocumentField = async ({
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
const deletedField = await tx.field.delete({
|
||||
where: {
|
||||
id: fieldId,
|
||||
envelopeId: envelope.id,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -98,7 +98,7 @@ export const deleteDocumentField = async ({
|
||||
await tx.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_DELETED,
|
||||
documentId,
|
||||
envelopeId: envelope.id,
|
||||
metadata: requestMetadata,
|
||||
data: {
|
||||
fieldId: deletedField.secondaryId,
|
||||
@@ -108,5 +108,7 @@ export const deleteDocumentField = async ({
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
return deletedField;
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
import type { Team } from '@prisma/client';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import type { RequestMetadata } from '../../universal/extract-request-metadata';
|
||||
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
|
||||
import { buildTeamWhereQuery } from '../../utils/teams';
|
||||
|
||||
export type DeleteFieldOptions = {
|
||||
fieldId: number;
|
||||
documentId: number;
|
||||
userId: number;
|
||||
teamId: number;
|
||||
requestMetadata?: RequestMetadata;
|
||||
};
|
||||
|
||||
export const deleteField = async ({
|
||||
fieldId,
|
||||
userId,
|
||||
teamId,
|
||||
documentId,
|
||||
requestMetadata,
|
||||
}: DeleteFieldOptions) => {
|
||||
const field = await prisma.field.delete({
|
||||
where: {
|
||||
id: fieldId,
|
||||
document: {
|
||||
id: documentId,
|
||||
team: buildTeamWhereQuery({ teamId, userId }),
|
||||
},
|
||||
},
|
||||
include: {
|
||||
recipient: true,
|
||||
},
|
||||
});
|
||||
|
||||
const user = await prisma.user.findFirstOrThrow({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
},
|
||||
});
|
||||
|
||||
let team: Team | null = null;
|
||||
|
||||
if (teamId) {
|
||||
team = await prisma.team.findFirstOrThrow({
|
||||
where: {
|
||||
id: teamId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: 'FIELD_DELETED',
|
||||
documentId,
|
||||
user: {
|
||||
id: team?.id ?? user.id,
|
||||
email: team?.name ?? user.email,
|
||||
name: team ? '' : user.name,
|
||||
},
|
||||
data: {
|
||||
fieldId: field.secondaryId,
|
||||
fieldRecipientEmail: field.recipient?.email ?? '',
|
||||
fieldRecipientId: field.recipientId ?? -1,
|
||||
fieldType: field.type,
|
||||
},
|
||||
requestMetadata,
|
||||
}),
|
||||
});
|
||||
|
||||
return field;
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user