mirror of
https://github.com/documenso/documenso.git
synced 2025-11-13 00:03:33 +10:00
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.
388 lines
12 KiB
TypeScript
388 lines
12 KiB
TypeScript
import { createElement } from 'react';
|
|
|
|
import { msg } from '@lingui/core/macro';
|
|
import type { Recipient } from '@prisma/client';
|
|
import { EnvelopeType, RecipientRole } from '@prisma/client';
|
|
import { SendStatus, SigningStatus } from '@prisma/client';
|
|
import { isDeepEqual } from 'remeda';
|
|
|
|
import { mailer } from '@documenso/email/mailer';
|
|
import RecipientRemovedFromDocumentTemplate from '@documenso/email/templates/recipient-removed-from-document';
|
|
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
|
import type { TRecipientAccessAuthTypes } from '@documenso/lib/types/document-auth';
|
|
import {
|
|
type TRecipientActionAuthTypes,
|
|
ZRecipientAuthOptionsSchema,
|
|
} from '@documenso/lib/types/document-auth';
|
|
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
|
import { nanoid } from '@documenso/lib/universal/id';
|
|
import {
|
|
createDocumentAuditLogData,
|
|
diffRecipientChanges,
|
|
} from '@documenso/lib/utils/document-audit-logs';
|
|
import { createRecipientAuthOptions } from '@documenso/lib/utils/document-auth';
|
|
import { prisma } from '@documenso/prisma';
|
|
|
|
import { getI18nInstance } from '../../client-only/providers/i18n-server';
|
|
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
|
|
import { AppError, AppErrorCode } from '../../errors/app-error';
|
|
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
|
|
import { type EnvelopeIdOptions, mapSecondaryIdToDocumentId } from '../../utils/envelope';
|
|
import { canRecipientBeModified } from '../../utils/recipients';
|
|
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
|
|
import { getEmailContext } from '../email/get-email-context';
|
|
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
|
|
|
|
export interface SetDocumentRecipientsOptions {
|
|
userId: number;
|
|
teamId: number;
|
|
id: EnvelopeIdOptions;
|
|
recipients: RecipientData[];
|
|
requestMetadata: ApiRequestMetadata;
|
|
}
|
|
|
|
export const setDocumentRecipients = async ({
|
|
userId,
|
|
teamId,
|
|
id,
|
|
recipients,
|
|
requestMetadata,
|
|
}: SetDocumentRecipientsOptions) => {
|
|
const { envelopeWhereInput } = await getEnvelopeWhereInput({
|
|
id,
|
|
type: EnvelopeType.DOCUMENT,
|
|
userId,
|
|
teamId,
|
|
});
|
|
|
|
const envelope = await prisma.envelope.findFirst({
|
|
where: envelopeWhereInput,
|
|
include: {
|
|
fields: true,
|
|
documentMeta: true,
|
|
team: {
|
|
select: {
|
|
organisation: {
|
|
select: {
|
|
organisationClaim: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
recipients: true,
|
|
},
|
|
});
|
|
|
|
const user = await prisma.user.findFirstOrThrow({
|
|
where: {
|
|
id: userId,
|
|
},
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
email: true,
|
|
},
|
|
});
|
|
|
|
if (!envelope) {
|
|
throw new Error('Document not found');
|
|
}
|
|
|
|
if (envelope.completedAt) {
|
|
throw new Error('Document already complete');
|
|
}
|
|
|
|
const { branding, emailLanguage, senderEmail, replyToEmail } = await getEmailContext({
|
|
emailType: 'RECIPIENT',
|
|
source: {
|
|
type: 'team',
|
|
teamId,
|
|
},
|
|
meta: envelope.documentMeta,
|
|
});
|
|
|
|
const recipientsHaveActionAuth = recipients.some(
|
|
(recipient) => recipient.actionAuth && recipient.actionAuth.length > 0,
|
|
);
|
|
|
|
// Check if user has permission to set the global action auth.
|
|
if (recipientsHaveActionAuth && !envelope.team.organisation.organisationClaim.flags.cfr21) {
|
|
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
|
message: 'You do not have permission to set the action auth',
|
|
});
|
|
}
|
|
|
|
const normalizedRecipients = recipients.map((recipient) => ({
|
|
...recipient,
|
|
email: recipient.email.toLowerCase(),
|
|
}));
|
|
|
|
const existingRecipients = envelope.recipients;
|
|
|
|
const removedRecipients = existingRecipients.filter(
|
|
(existingRecipient) =>
|
|
!normalizedRecipients.find((recipient) => recipient.id === existingRecipient.id),
|
|
);
|
|
|
|
const linkedRecipients = normalizedRecipients.map((recipient) => {
|
|
const existing = existingRecipients.find(
|
|
(existingRecipient) => existingRecipient.id === recipient.id,
|
|
);
|
|
|
|
const canPersistedRecipientBeModified =
|
|
existing && canRecipientBeModified(existing, envelope.fields);
|
|
|
|
if (
|
|
existing &&
|
|
hasRecipientBeenChanged(existing, recipient) &&
|
|
!canRecipientBeModified(existing, envelope.fields)
|
|
) {
|
|
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
|
message: 'Cannot modify a recipient who has already interacted with the document',
|
|
});
|
|
}
|
|
|
|
return {
|
|
...recipient,
|
|
_persisted: existing,
|
|
canPersistedRecipientBeModified,
|
|
};
|
|
});
|
|
|
|
const persistedRecipients = await prisma.$transaction(async (tx) => {
|
|
return await Promise.all(
|
|
linkedRecipients.map(async (recipient) => {
|
|
let authOptions = ZRecipientAuthOptionsSchema.parse(recipient._persisted?.authOptions);
|
|
|
|
if (recipient.actionAuth !== undefined || recipient.accessAuth !== undefined) {
|
|
authOptions = createRecipientAuthOptions({
|
|
accessAuth: recipient.accessAuth || authOptions.accessAuth,
|
|
actionAuth: recipient.actionAuth || authOptions.actionAuth,
|
|
});
|
|
}
|
|
|
|
if (recipient._persisted && !recipient.canPersistedRecipientBeModified) {
|
|
return {
|
|
...recipient._persisted,
|
|
clientId: recipient.clientId,
|
|
};
|
|
}
|
|
|
|
const upsertedRecipient = await tx.recipient.upsert({
|
|
where: {
|
|
id: recipient._persisted?.id ?? -1,
|
|
envelopeId: envelope.id,
|
|
},
|
|
update: {
|
|
name: recipient.name,
|
|
email: recipient.email,
|
|
role: recipient.role,
|
|
signingOrder: recipient.signingOrder,
|
|
envelopeId: envelope.id,
|
|
sendStatus: recipient.role === RecipientRole.CC ? SendStatus.SENT : SendStatus.NOT_SENT,
|
|
signingStatus:
|
|
recipient.role === RecipientRole.CC ? SigningStatus.SIGNED : SigningStatus.NOT_SIGNED,
|
|
authOptions,
|
|
},
|
|
create: {
|
|
name: recipient.name,
|
|
email: recipient.email,
|
|
role: recipient.role,
|
|
signingOrder: recipient.signingOrder,
|
|
token: nanoid(),
|
|
envelopeId: envelope.id,
|
|
sendStatus: recipient.role === RecipientRole.CC ? SendStatus.SENT : SendStatus.NOT_SENT,
|
|
signingStatus:
|
|
recipient.role === RecipientRole.CC ? SigningStatus.SIGNED : SigningStatus.NOT_SIGNED,
|
|
authOptions,
|
|
},
|
|
});
|
|
|
|
const recipientId = upsertedRecipient.id;
|
|
|
|
// Clear all fields if the recipient role is changed to a type that cannot have fields.
|
|
if (
|
|
recipient._persisted &&
|
|
recipient._persisted.role !== recipient.role &&
|
|
(recipient.role === RecipientRole.CC || recipient.role === RecipientRole.VIEWER)
|
|
) {
|
|
await tx.field.deleteMany({
|
|
where: {
|
|
recipientId,
|
|
},
|
|
});
|
|
}
|
|
|
|
const baseAuditLog = {
|
|
recipientEmail: upsertedRecipient.email,
|
|
recipientName: upsertedRecipient.name,
|
|
recipientId,
|
|
recipientRole: upsertedRecipient.role,
|
|
};
|
|
|
|
const changes = recipient._persisted
|
|
? diffRecipientChanges(recipient._persisted, upsertedRecipient)
|
|
: [];
|
|
|
|
// Handle recipient updated audit log.
|
|
if (recipient._persisted && changes.length > 0) {
|
|
await tx.documentAuditLog.create({
|
|
data: createDocumentAuditLogData({
|
|
type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_UPDATED,
|
|
envelopeId: envelope.id,
|
|
metadata: requestMetadata,
|
|
data: {
|
|
changes,
|
|
...baseAuditLog,
|
|
},
|
|
}),
|
|
});
|
|
}
|
|
|
|
// Handle recipient created audit log.
|
|
if (!recipient._persisted) {
|
|
await tx.documentAuditLog.create({
|
|
data: createDocumentAuditLogData({
|
|
type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_CREATED,
|
|
envelopeId: envelope.id,
|
|
metadata: requestMetadata,
|
|
data: {
|
|
...baseAuditLog,
|
|
accessAuth: recipient.accessAuth || [],
|
|
actionAuth: recipient.actionAuth || [],
|
|
},
|
|
}),
|
|
});
|
|
}
|
|
|
|
return {
|
|
...upsertedRecipient,
|
|
clientId: recipient.clientId,
|
|
};
|
|
}),
|
|
);
|
|
});
|
|
|
|
if (removedRecipients.length > 0) {
|
|
await prisma.$transaction(async (tx) => {
|
|
await tx.recipient.deleteMany({
|
|
where: {
|
|
id: {
|
|
in: removedRecipients.map((recipient) => recipient.id),
|
|
},
|
|
},
|
|
});
|
|
|
|
await tx.documentAuditLog.createMany({
|
|
data: removedRecipients.map((recipient) =>
|
|
createDocumentAuditLogData({
|
|
type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_DELETED,
|
|
envelopeId: envelope.id,
|
|
metadata: requestMetadata,
|
|
data: {
|
|
recipientEmail: recipient.email,
|
|
recipientName: recipient.name,
|
|
recipientId: recipient.id,
|
|
recipientRole: recipient.role,
|
|
},
|
|
}),
|
|
),
|
|
});
|
|
});
|
|
|
|
const isRecipientRemovedEmailEnabled = extractDerivedDocumentEmailSettings(
|
|
envelope.documentMeta,
|
|
).recipientRemoved;
|
|
|
|
// Send emails to deleted recipients.
|
|
await Promise.all(
|
|
removedRecipients.map(async (recipient) => {
|
|
if (recipient.sendStatus !== SendStatus.SENT || !isRecipientRemovedEmailEnabled) {
|
|
return;
|
|
}
|
|
|
|
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
|
|
|
|
const template = createElement(RecipientRemovedFromDocumentTemplate, {
|
|
documentName: envelope.title,
|
|
inviterName: user.name || undefined,
|
|
assetBaseUrl,
|
|
});
|
|
|
|
const [html, text] = await Promise.all([
|
|
renderEmailWithI18N(template, { lang: emailLanguage, branding }),
|
|
renderEmailWithI18N(template, { lang: emailLanguage, branding, plainText: true }),
|
|
]);
|
|
|
|
const i18n = await getI18nInstance(emailLanguage);
|
|
|
|
await mailer.sendMail({
|
|
to: {
|
|
address: recipient.email,
|
|
name: recipient.name,
|
|
},
|
|
from: senderEmail,
|
|
replyTo: replyToEmail,
|
|
subject: i18n._(msg`You have been removed from a document`),
|
|
html,
|
|
text,
|
|
});
|
|
}),
|
|
);
|
|
}
|
|
|
|
// Filter out recipients that have been removed or have been updated.
|
|
const filteredRecipients: RecipientDataWithClientId[] = existingRecipients.filter((recipient) => {
|
|
const isRemoved = removedRecipients.find(
|
|
(removedRecipient) => removedRecipient.id === recipient.id,
|
|
);
|
|
const isUpdated = persistedRecipients.find(
|
|
(persistedRecipient) => persistedRecipient.id === recipient.id,
|
|
);
|
|
|
|
return !isRemoved && !isUpdated;
|
|
});
|
|
|
|
return {
|
|
recipients: [...filteredRecipients, ...persistedRecipients].map((recipient) => ({
|
|
...recipient,
|
|
documentId: mapSecondaryIdToDocumentId(envelope.secondaryId),
|
|
templateId: null,
|
|
})),
|
|
};
|
|
};
|
|
|
|
/**
|
|
* If you change this you MUST update the `hasRecipientBeenChanged` function.
|
|
*/
|
|
type RecipientData = {
|
|
id?: number | null;
|
|
clientId?: string | null;
|
|
email: string;
|
|
name: string;
|
|
role: RecipientRole;
|
|
signingOrder?: number | null;
|
|
accessAuth?: TRecipientAccessAuthTypes[];
|
|
actionAuth?: TRecipientActionAuthTypes[];
|
|
};
|
|
|
|
type RecipientDataWithClientId = Recipient & {
|
|
clientId?: string | null;
|
|
};
|
|
|
|
const hasRecipientBeenChanged = (recipient: Recipient, newRecipientData: RecipientData) => {
|
|
const authOptions = ZRecipientAuthOptionsSchema.parse(recipient.authOptions);
|
|
|
|
const newRecipientAccessAuth = newRecipientData.accessAuth || [];
|
|
const newRecipientActionAuth = newRecipientData.actionAuth || [];
|
|
|
|
return (
|
|
recipient.email !== newRecipientData.email ||
|
|
recipient.name !== newRecipientData.name ||
|
|
recipient.role !== newRecipientData.role ||
|
|
recipient.signingOrder !== newRecipientData.signingOrder ||
|
|
!isDeepEqual(authOptions.accessAuth, newRecipientAccessAuth) ||
|
|
!isDeepEqual(authOptions.actionAuth, newRecipientActionAuth)
|
|
);
|
|
};
|