mirror of
https://github.com/documenso/documenso.git
synced 2026-10-04 02:33:54 +10:00
fix: audit logs
This commit is contained in:
@@ -14,7 +14,7 @@ import type {
|
||||
TUseEnvelopeResponse,
|
||||
} from '@documenso/trpc/server/envelope-router/use-envelope.types';
|
||||
import { type APIRequestContext, expect, test } from '@playwright/test';
|
||||
import { EnvelopeType } from '@prisma/client';
|
||||
import { EnvelopeType, FieldType, RecipientRole } from '@prisma/client';
|
||||
|
||||
import { createImageFile } from '../../fixtures/contents';
|
||||
|
||||
@@ -56,7 +56,12 @@ const IMAGE_META: TEnvelopeContentMetaInput = {
|
||||
* Create an envelope with two files through the public create route, with a
|
||||
* text on each file and an image on the second.
|
||||
*/
|
||||
const createSourceEnvelope = async (request: APIRequestContext, token: string, type: EnvelopeType) => {
|
||||
const createSourceEnvelope = async (
|
||||
request: APIRequestContext,
|
||||
token: string,
|
||||
type: EnvelopeType,
|
||||
options: { withSignerFields?: boolean } = {},
|
||||
) => {
|
||||
const image = await createImageFile(`copy-${nanoid()}.png`, 120, 60);
|
||||
|
||||
const formData = new FormData();
|
||||
@@ -66,6 +71,19 @@ const createSourceEnvelope = async (request: APIRequestContext, token: string, t
|
||||
JSON.stringify({
|
||||
type,
|
||||
title: 'Envelope With Contents',
|
||||
recipients: options.withSignerFields
|
||||
? [
|
||||
{
|
||||
email: `signer-${nanoid()}@test.documenso.com`,
|
||||
name: 'Signer',
|
||||
role: RecipientRole.SIGNER,
|
||||
fields: [
|
||||
{ type: FieldType.SIGNATURE, page: 1, positionX: 10, positionY: 60, width: 10, height: 5 },
|
||||
{ type: FieldType.NAME, page: 1, positionX: 10, positionY: 70, width: 10, height: 5 },
|
||||
],
|
||||
},
|
||||
]
|
||||
: undefined,
|
||||
contents: [
|
||||
{ identifier: 'first.pdf', contentMeta: textMeta('On the first file') },
|
||||
{ identifier: 'second.pdf', contentMeta: textMeta('On the second file') },
|
||||
@@ -196,6 +214,16 @@ const COPY_ROUTES: CopyRoute[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const getCopyRoute = (name: string): CopyRoute => {
|
||||
const route = COPY_ROUTES.find((copyRoute) => copyRoute.name === name);
|
||||
|
||||
if (!route) {
|
||||
throw new Error(`Unknown copy route ${name}`);
|
||||
}
|
||||
|
||||
return route;
|
||||
};
|
||||
|
||||
test.describe('Copy contents to new envelopes', () => {
|
||||
let token: string;
|
||||
|
||||
@@ -235,4 +263,80 @@ test.describe('Copy contents to new envelopes', () => {
|
||||
expect(await prisma.envelopeContent.count({ where: { envelopeId: sourceId } })).toBe(3);
|
||||
});
|
||||
}
|
||||
|
||||
test('envelope/duplicate logs the copied document, fields and contents', async ({ request }) => {
|
||||
const sourceId = await createSourceEnvelope(request, token, EnvelopeType.DOCUMENT, { withSignerFields: true });
|
||||
|
||||
const sourceAuditLogCount = await prisma.documentAuditLog.count({ where: { envelopeId: sourceId } });
|
||||
|
||||
const copyId = await getCopyRoute('envelope/duplicate').copy({ request, token, sourceId });
|
||||
|
||||
const copy = await prisma.envelope.findUniqueOrThrow({
|
||||
where: { id: copyId },
|
||||
include: { recipients: true, fields: true, contents: true },
|
||||
});
|
||||
|
||||
const auditLogs = await prisma.documentAuditLog.findMany({ where: { envelopeId: copyId } });
|
||||
|
||||
const logsOfType = (type: string) => auditLogs.filter((log) => log.type === type);
|
||||
|
||||
// The copy's own creation is on record, with the copied title.
|
||||
expect(logsOfType('DOCUMENT_CREATED')).toHaveLength(1);
|
||||
expect(logsOfType('DOCUMENT_CREATED')[0].data).toMatchObject({ title: copy.title });
|
||||
|
||||
// One entry per copied field, naming the copy's own field and recipient IDs.
|
||||
const [signer] = copy.recipients;
|
||||
|
||||
expect(copy.fields).toHaveLength(2);
|
||||
|
||||
expect(logsOfType('FIELD_CREATED').map((log) => log.data)).toEqual(
|
||||
expect.arrayContaining(
|
||||
copy.fields.map((field) => ({
|
||||
fieldId: field.secondaryId,
|
||||
fieldRecipientEmail: signer.email,
|
||||
fieldRecipientId: signer.id,
|
||||
fieldType: field.type,
|
||||
})),
|
||||
),
|
||||
);
|
||||
|
||||
expect(logsOfType('FIELD_CREATED')).toHaveLength(2);
|
||||
|
||||
// One entry per copied content, naming the copy's own content and item IDs.
|
||||
expect(copy.contents).toHaveLength(3);
|
||||
|
||||
expect(logsOfType('CONTENT_CREATED').map((log) => log.data)).toEqual(
|
||||
expect.arrayContaining(
|
||||
copy.contents.map((content) => ({
|
||||
contentId: content.id,
|
||||
contentType: content.contentMeta.type,
|
||||
envelopeItemId: content.envelopeItemId,
|
||||
contentMeta: content.contentMeta,
|
||||
dataContentId: content.dataContentId,
|
||||
})),
|
||||
),
|
||||
);
|
||||
|
||||
expect(logsOfType('CONTENT_CREATED')).toHaveLength(3);
|
||||
|
||||
// Nothing was written against the source.
|
||||
expect(await prisma.documentAuditLog.count({ where: { envelopeId: sourceId } })).toBe(sourceAuditLogCount);
|
||||
});
|
||||
|
||||
test('envelope/duplicate logs nothing for a template', async ({ request }) => {
|
||||
const sourceId = await createSourceEnvelope(request, token, EnvelopeType.TEMPLATE, { withSignerFields: true });
|
||||
|
||||
const copyId = await getCopyRoute('envelope/duplicate').copy({ request, token, sourceId });
|
||||
|
||||
const copy = await prisma.envelope.findUniqueOrThrow({
|
||||
where: { id: copyId },
|
||||
include: { fields: true, contents: true },
|
||||
});
|
||||
|
||||
// The fields and contents are copied, but a template has no audit trail.
|
||||
expect(copy.type).toBe(EnvelopeType.TEMPLATE);
|
||||
expect(copy.fields).toHaveLength(2);
|
||||
expect(copy.contents).toHaveLength(3);
|
||||
expect(await prisma.documentAuditLog.count({ where: { envelopeId: copyId } })).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -154,12 +154,14 @@ const imageMeta = (positionY: number): TEnvelopeContentMetaInput => ({
|
||||
|
||||
test.describe('Create envelope with contents', () => {
|
||||
let team: Team;
|
||||
let userId: number;
|
||||
let token: string;
|
||||
|
||||
test.beforeEach(async () => {
|
||||
const seeded = await seedUser();
|
||||
|
||||
team = seeded.team;
|
||||
userId = seeded.user.id;
|
||||
|
||||
({ token } = await createApiToken({
|
||||
userId: seeded.user.id,
|
||||
@@ -434,6 +436,114 @@ test.describe('Create envelope with contents', () => {
|
||||
});
|
||||
}
|
||||
|
||||
test('logs each created field and content on a document', async ({ request }) => {
|
||||
const signerEmail = `signer-${nanoid()}@test.documenso.com`;
|
||||
|
||||
const res = await createEnvelopeWithContents({
|
||||
request,
|
||||
token,
|
||||
payload: {
|
||||
recipients: [
|
||||
{
|
||||
email: signerEmail,
|
||||
name: 'Signer',
|
||||
role: RecipientRole.SIGNER,
|
||||
fields: [
|
||||
{ type: FieldType.SIGNATURE, page: 1, positionX: 10, positionY: 10, width: 10, height: 5 },
|
||||
{ type: FieldType.NAME, page: 1, positionX: 10, positionY: 20, width: 10, height: 5 },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
contents: [{ contentMeta: textMeta('Hello') }, { contentMeta: RECTANGLE_META }],
|
||||
});
|
||||
|
||||
expect(res.ok(), await res.text()).toBeTruthy();
|
||||
|
||||
const { id } = (await res.json()) as TCreateEnvelopeResponse;
|
||||
|
||||
const envelope = await prisma.envelope.findUniqueOrThrow({
|
||||
where: { id },
|
||||
include: { recipients: true, fields: true, contents: true },
|
||||
});
|
||||
|
||||
const auditLogs = await prisma.documentAuditLog.findMany({
|
||||
where: { envelopeId: id, type: { in: ['FIELD_CREATED', 'CONTENT_CREATED'] } },
|
||||
});
|
||||
|
||||
const fieldLogs = auditLogs.filter((log) => log.type === 'FIELD_CREATED');
|
||||
const contentLogs = auditLogs.filter((log) => log.type === 'CONTENT_CREATED');
|
||||
|
||||
// Attributed to the user who made the request.
|
||||
for (const log of auditLogs) {
|
||||
expect(log.userId).toBe(userId);
|
||||
}
|
||||
|
||||
// One entry per field, carrying the same identifiers the editor's logs do.
|
||||
expect(fieldLogs).toHaveLength(2);
|
||||
|
||||
const [signer] = envelope.recipients;
|
||||
|
||||
const byFieldId = (a: { fieldId: string }, b: { fieldId: string }) => a.fieldId.localeCompare(b.fieldId);
|
||||
|
||||
const loggedFields = fieldLogs.map((log) => log.data as { fieldId: string }).sort(byFieldId);
|
||||
|
||||
const expectedFields = envelope.fields
|
||||
.map((field) => ({
|
||||
fieldId: field.secondaryId,
|
||||
fieldRecipientEmail: signer.email,
|
||||
fieldRecipientId: signer.id,
|
||||
fieldType: field.type,
|
||||
}))
|
||||
.sort(byFieldId);
|
||||
|
||||
expect(loggedFields).toEqual(expectedFields);
|
||||
|
||||
// One entry per content, with the full meta as created.
|
||||
expect(contentLogs).toHaveLength(2);
|
||||
|
||||
for (const content of envelope.contents) {
|
||||
const log = contentLogs.find((log) => {
|
||||
const data = log.data as { contentId?: string };
|
||||
|
||||
return data.contentId === content.id;
|
||||
});
|
||||
|
||||
expect(log?.data).toEqual({
|
||||
contentId: content.id,
|
||||
contentType: content.contentMeta.type,
|
||||
envelopeItemId: content.envelopeItemId,
|
||||
contentMeta: content.contentMeta,
|
||||
dataContentId: null,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('does not log fields or contents on a template', async ({ request }) => {
|
||||
const res = await createEnvelopeWithContents({
|
||||
request,
|
||||
token,
|
||||
payload: {
|
||||
type: EnvelopeType.TEMPLATE,
|
||||
recipients: [
|
||||
{
|
||||
email: `signer-${nanoid()}@test.documenso.com`,
|
||||
name: 'Signer',
|
||||
role: RecipientRole.SIGNER,
|
||||
fields: [{ type: FieldType.SIGNATURE, page: 1, positionX: 10, positionY: 10, width: 10, height: 5 }],
|
||||
},
|
||||
],
|
||||
},
|
||||
contents: [{ contentMeta: textMeta('Hello') }],
|
||||
});
|
||||
|
||||
expect(res.ok(), await res.text()).toBeTruthy();
|
||||
|
||||
const { id } = (await res.json()) as TCreateEnvelopeResponse;
|
||||
|
||||
expect(await prisma.documentAuditLog.count({ where: { envelopeId: id } })).toBe(0);
|
||||
});
|
||||
|
||||
test('rejects an image which is not really a PNG, JPEG or WebP', async ({ request }) => {
|
||||
const externalId = `e2e-contents-format-${nanoid()}`;
|
||||
const image = createGifLabelledAsPng(`format-${nanoid()}.png`);
|
||||
|
||||
@@ -7,6 +7,7 @@ import { ZDefaultRecipientsSchema } from '@documenso/lib/types/default-recipient
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
||||
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||
import { generateDatabaseId, nanoid, prefixedId } from '@documenso/lib/universal/id';
|
||||
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 type { DocumentMeta, DocumentVisibility, TemplateType } from '@prisma/client';
|
||||
@@ -516,25 +517,29 @@ export const createEnvelope = async ({
|
||||
}),
|
||||
);
|
||||
|
||||
if (contents.length > 0) {
|
||||
// Built up front so the generated IDs can be reused for the audit logs
|
||||
// without reading the rows back.
|
||||
const contentsToCreate = contents.map((content) => {
|
||||
const envelopeItem = envelope.envelopeItems.find((item) => item.documentDataId === content.documentDataId);
|
||||
|
||||
if (!envelopeItem) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Document data not found',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
id: generateDatabaseId('envelope_content'),
|
||||
envelopeId: envelope.id,
|
||||
envelopeItemId: envelopeItem.id,
|
||||
contentMeta: content.contentMeta,
|
||||
dataContentId: content.dataContentId,
|
||||
};
|
||||
});
|
||||
|
||||
if (contentsToCreate.length > 0) {
|
||||
await tx.envelopeContent.createMany({
|
||||
data: contents.map((content) => {
|
||||
const envelopeItem = envelope.envelopeItems.find((item) => item.documentDataId === content.documentDataId);
|
||||
|
||||
if (!envelopeItem) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Document data not found',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
id: generateDatabaseId('envelope_content'),
|
||||
envelopeId: envelope.id,
|
||||
envelopeItemId: envelopeItem.id,
|
||||
contentMeta: content.contentMeta,
|
||||
dataContentId: content.dataContentId,
|
||||
};
|
||||
}),
|
||||
data: contentsToCreate,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -708,6 +713,57 @@ export const createEnvelope = async ({
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
const fieldAuditLogs: CreateDocumentAuditLogDataResponse[] = createdEnvelope.fields.map((field) => {
|
||||
const fieldRecipient = createdEnvelope.recipients.find((recipient) => recipient.id === field.recipientId);
|
||||
|
||||
if (!fieldRecipient) {
|
||||
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
|
||||
message: `Recipient ${field.recipientId} not found for field ${field.secondaryId}`,
|
||||
});
|
||||
}
|
||||
|
||||
return createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_CREATED,
|
||||
envelopeId: envelope.id,
|
||||
user: {
|
||||
id: userId,
|
||||
},
|
||||
metadata: requestMetadata,
|
||||
data: {
|
||||
fieldId: field.secondaryId,
|
||||
fieldRecipientEmail: fieldRecipient.email,
|
||||
fieldRecipientId: fieldRecipient.id,
|
||||
fieldType: field.type,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const contentAuditLogs: CreateDocumentAuditLogDataResponse[] = contentsToCreate.map((content) =>
|
||||
createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.CONTENT_CREATED,
|
||||
envelopeId: envelope.id,
|
||||
user: {
|
||||
id: userId,
|
||||
},
|
||||
metadata: requestMetadata,
|
||||
data: {
|
||||
contentId: content.id,
|
||||
contentType: content.contentMeta.type,
|
||||
envelopeItemId: content.envelopeItemId,
|
||||
contentMeta: content.contentMeta,
|
||||
dataContentId: content.dataContentId,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const itemAuditLogs = [...fieldAuditLogs, ...contentAuditLogs];
|
||||
|
||||
if (itemAuditLogs.length > 0) {
|
||||
await tx.documentAuditLog.createMany({
|
||||
data: itemAuditLogs,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return createdEnvelope;
|
||||
|
||||
@@ -4,9 +4,13 @@ import pMap from 'p-map';
|
||||
import { omit } from 'remeda';
|
||||
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
|
||||
import { ZSignatureLevelSchema } from '../../types/signature-level';
|
||||
import { mapEnvelopeToWebhookDocumentPayload, ZWebhookDocumentSchema } from '../../types/webhook-payload';
|
||||
import type { ApiRequestMetadata } from '../../universal/extract-request-metadata';
|
||||
import { nanoid, prefixedId } from '../../universal/id';
|
||||
import type { CreateDocumentAuditLogDataResponse } from '../../utils/document-audit-logs';
|
||||
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
|
||||
import type { EnvelopeIdOptions } from '../../utils/envelope';
|
||||
import { buildEnvelopeContentCopyData } from '../../utils/envelope-content';
|
||||
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
|
||||
@@ -19,6 +23,7 @@ export interface DuplicateEnvelopeOptions {
|
||||
id: EnvelopeIdOptions;
|
||||
userId: number;
|
||||
teamId: number;
|
||||
requestMetadata: ApiRequestMetadata;
|
||||
overrides?: {
|
||||
duplicateAsTemplate?: boolean;
|
||||
includeRecipients?: boolean;
|
||||
@@ -27,7 +32,13 @@ export interface DuplicateEnvelopeOptions {
|
||||
};
|
||||
}
|
||||
|
||||
export const duplicateEnvelope = async ({ id, userId, teamId, overrides }: DuplicateEnvelopeOptions) => {
|
||||
export const duplicateEnvelope = async ({
|
||||
id,
|
||||
userId,
|
||||
teamId,
|
||||
requestMetadata,
|
||||
overrides,
|
||||
}: DuplicateEnvelopeOptions) => {
|
||||
const {
|
||||
duplicateAsTemplate = false,
|
||||
includeRecipients = true,
|
||||
@@ -189,11 +200,37 @@ export const duplicateEnvelope = async ({ id, userId, teamId, overrides }: Dupli
|
||||
}),
|
||||
);
|
||||
|
||||
const auditLogs: CreateDocumentAuditLogDataResponse[] = [];
|
||||
|
||||
const isAuditLogRequired = duplicatedEnvelope.type === EnvelopeType.DOCUMENT;
|
||||
|
||||
if (isAuditLogRequired) {
|
||||
auditLogs.push(
|
||||
createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_CREATED,
|
||||
envelopeId: duplicatedEnvelope.id,
|
||||
user: {
|
||||
id: userId,
|
||||
},
|
||||
metadata: requestMetadata,
|
||||
data: {
|
||||
title: duplicatedEnvelope.title,
|
||||
source: {
|
||||
type: DocumentSource.DOCUMENT,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (includeRecipients) {
|
||||
await pMap(
|
||||
const duplicatedRecipients = await pMap(
|
||||
envelope.recipients,
|
||||
async (recipient) =>
|
||||
prisma.recipient.create({
|
||||
include: {
|
||||
fields: true,
|
||||
},
|
||||
data: {
|
||||
envelopeId: duplicatedEnvelope.id,
|
||||
email: recipient.email,
|
||||
@@ -224,6 +261,29 @@ export const duplicateEnvelope = async ({ id, userId, teamId, overrides }: Dupli
|
||||
}),
|
||||
{ concurrency: 5 },
|
||||
);
|
||||
|
||||
if (isAuditLogRequired) {
|
||||
const fieldAuditLogs = duplicatedRecipients.flatMap((recipient) =>
|
||||
recipient.fields.map((field) =>
|
||||
createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_CREATED,
|
||||
envelopeId: duplicatedEnvelope.id,
|
||||
user: {
|
||||
id: userId,
|
||||
},
|
||||
metadata: requestMetadata,
|
||||
data: {
|
||||
fieldId: field.secondaryId,
|
||||
fieldRecipientEmail: recipient.email,
|
||||
fieldRecipientId: recipient.id,
|
||||
fieldType: field.type,
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
auditLogs.push(...fieldAuditLogs);
|
||||
}
|
||||
}
|
||||
|
||||
if (includeContents) {
|
||||
@@ -238,6 +298,34 @@ export const duplicateEnvelope = async ({ id, userId, teamId, overrides }: Dupli
|
||||
data: contentsToCreate,
|
||||
});
|
||||
}
|
||||
|
||||
if (isAuditLogRequired) {
|
||||
const contentsAuditLogs = contentsToCreate.map((content) =>
|
||||
createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.CONTENT_CREATED,
|
||||
envelopeId: duplicatedEnvelope.id,
|
||||
user: {
|
||||
id: userId,
|
||||
},
|
||||
metadata: requestMetadata,
|
||||
data: {
|
||||
contentId: content.id,
|
||||
contentType: content.contentMeta.type,
|
||||
envelopeItemId: content.envelopeItemId,
|
||||
contentMeta: content.contentMeta,
|
||||
dataContentId: content.dataContentId ?? null,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
auditLogs.push(...contentsAuditLogs);
|
||||
}
|
||||
}
|
||||
|
||||
if (auditLogs.length > 0) {
|
||||
await prisma.documentAuditLog.createMany({
|
||||
data: auditLogs,
|
||||
});
|
||||
}
|
||||
|
||||
if (duplicatedEnvelope.type === EnvelopeType.DOCUMENT) {
|
||||
|
||||
@@ -28,6 +28,7 @@ export const duplicateDocumentRoute = authenticatedProcedure
|
||||
},
|
||||
userId: user.id,
|
||||
teamId,
|
||||
requestMetadata: ctx.metadata,
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
@@ -28,6 +28,7 @@ export const duplicateEnvelopeRoute = authenticatedProcedure
|
||||
type: 'envelopeId',
|
||||
id: envelopeId,
|
||||
},
|
||||
requestMetadata: ctx.metadata,
|
||||
overrides: {
|
||||
includeRecipients,
|
||||
includeFields,
|
||||
|
||||
@@ -23,6 +23,7 @@ export const saveAsTemplateRoute = authenticatedProcedure
|
||||
type: 'envelopeId',
|
||||
id: envelopeId,
|
||||
},
|
||||
requestMetadata: ctx.metadata,
|
||||
overrides: {
|
||||
duplicateAsTemplate: true,
|
||||
includeRecipients,
|
||||
|
||||
@@ -455,6 +455,7 @@ export const templateRouter = router({
|
||||
type: 'templateId',
|
||||
id: templateId,
|
||||
},
|
||||
requestMetadata: ctx.metadata,
|
||||
});
|
||||
|
||||
return mapEnvelopeToTemplateLite(duplicatedEnvelope.envelope);
|
||||
|
||||
Reference in New Issue
Block a user