mirror of
https://github.com/documenso/documenso.git
synced 2026-07-24 08:54:20 +10:00
chore: merge origin/main into pr-2889 (resolve cancelled/expired status conflicts)
This commit is contained in:
@@ -0,0 +1,129 @@
|
||||
import { jobs } from '@documenso/lib/jobs/client';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { DocumentStatus, EnvelopeType, WebhookTriggerEvents } from '@prisma/client';
|
||||
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
|
||||
import { mapEnvelopeToWebhookDocumentPayload, ZWebhookDocumentSchema } from '../../types/webhook-payload';
|
||||
import type { ApiRequestMetadata } from '../../universal/extract-request-metadata';
|
||||
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
|
||||
import type { EnvelopeIdOptions } from '../../utils/envelope';
|
||||
import { mapSecondaryIdToDocumentId, unsafeBuildEnvelopeIdQuery } from '../../utils/envelope';
|
||||
import { isMemberManagerOrAbove } from '../../utils/teams';
|
||||
import { getMemberRoles } from '../team/get-member-roles';
|
||||
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||
|
||||
export type CancelDocumentOptions = {
|
||||
id: EnvelopeIdOptions;
|
||||
userId: number;
|
||||
teamId: number;
|
||||
reason?: string;
|
||||
requestMetadata: ApiRequestMetadata;
|
||||
};
|
||||
|
||||
export const cancelDocument = async ({ id, userId, teamId, reason, requestMetadata }: CancelDocumentOptions) => {
|
||||
// Note: This is an unsafe request, we validate the ownership/permission later in the function.
|
||||
const envelope = await prisma.envelope.findUnique({
|
||||
where: unsafeBuildEnvelopeIdQuery(id, EnvelopeType.DOCUMENT),
|
||||
include: {
|
||||
recipients: true,
|
||||
documentMeta: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!envelope) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Document not found',
|
||||
});
|
||||
}
|
||||
|
||||
const isUserOwner = envelope.userId === userId;
|
||||
|
||||
const teamRole = await getMemberRoles({
|
||||
teamId: envelope.teamId,
|
||||
reference: {
|
||||
type: 'User',
|
||||
id: userId,
|
||||
},
|
||||
})
|
||||
.then((roles) => roles.teamRole)
|
||||
.catch(() => null);
|
||||
|
||||
const isUserTeamMember = teamRole !== null;
|
||||
|
||||
// Callers with no relationship to the document must not be able to determine
|
||||
// whether it exists, so respond as if it was not found.
|
||||
if (!isUserOwner && !isUserTeamMember) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Document not found',
|
||||
});
|
||||
}
|
||||
|
||||
const isPrivilegedTeamMember = teamRole && isMemberManagerOrAbove(teamRole);
|
||||
|
||||
// The document is visible to the caller, but cancelling requires elevated permissions.
|
||||
if (!isUserOwner && !isPrivilegedTeamMember) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'Not allowed',
|
||||
});
|
||||
}
|
||||
|
||||
if (envelope.status !== DocumentStatus.PENDING) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'Only pending documents can be cancelled',
|
||||
});
|
||||
}
|
||||
|
||||
const updatedEnvelope = await prisma.$transaction(async (tx) => {
|
||||
const updated = await tx.envelope.update({
|
||||
where: {
|
||||
id: envelope.id,
|
||||
},
|
||||
data: {
|
||||
status: DocumentStatus.CANCELLED,
|
||||
completedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
await tx.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
envelopeId: envelope.id,
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_CANCELLED,
|
||||
metadata: requestMetadata,
|
||||
data: {
|
||||
reason,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
return updated;
|
||||
});
|
||||
|
||||
const legacyDocumentId = mapSecondaryIdToDocumentId(envelope.secondaryId);
|
||||
|
||||
// Send cancellation emails to recipients via the resilient background job.
|
||||
await jobs.triggerJob({
|
||||
name: 'send.document.cancelled.emails',
|
||||
payload: {
|
||||
documentId: legacyDocumentId,
|
||||
cancellationReason: reason,
|
||||
requestMetadata: requestMetadata.requestMetadata,
|
||||
},
|
||||
});
|
||||
|
||||
// Trigger the webhook with the updated (cancelled) envelope payload.
|
||||
await triggerWebhook({
|
||||
event: WebhookTriggerEvents.DOCUMENT_CANCELLED,
|
||||
data: ZWebhookDocumentSchema.parse(
|
||||
mapEnvelopeToWebhookDocumentPayload({
|
||||
...envelope,
|
||||
status: updatedEnvelope.status,
|
||||
completedAt: updatedEnvelope.completedAt,
|
||||
}),
|
||||
),
|
||||
userId,
|
||||
teamId,
|
||||
});
|
||||
|
||||
return updatedEnvelope;
|
||||
};
|
||||
@@ -29,7 +29,6 @@ import { assertRecipientNotExpired } from '../../utils/recipients';
|
||||
import { getIsRecipientsTurnToSign } from '../recipient/get-is-recipient-turn';
|
||||
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||
import { isRecipientAuthorized } from './is-recipient-authorized';
|
||||
import { sendPendingEmail } from './send-pending-email';
|
||||
|
||||
export type CompleteDocumentWithTokenOptions = {
|
||||
token: string;
|
||||
@@ -389,7 +388,13 @@ export const completeDocumentWithToken = async ({
|
||||
});
|
||||
|
||||
if (pendingRecipients.length > 0) {
|
||||
await sendPendingEmail({ id, recipientId: recipient.id });
|
||||
await jobs.triggerJob({
|
||||
name: 'send.document.pending.email',
|
||||
payload: {
|
||||
envelopeId: envelope.id,
|
||||
recipientId: recipient.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (envelope.documentMeta?.signingOrder === DocumentSigningOrder.SEQUENTIAL) {
|
||||
const [nextRecipient] = pendingRecipients;
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
import { mailer } from '@documenso/email/mailer';
|
||||
import DocumentCancelTemplate from '@documenso/email/templates/document-cancel';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import type { DocumentMeta, Envelope, Recipient, User } from '@prisma/client';
|
||||
import { DocumentStatus, EnvelopeType, SendStatus, WebhookTriggerEvents } from '@prisma/client';
|
||||
import { createElement } from 'react';
|
||||
import { DocumentStatus, EnvelopeType, RecipientRole, SendStatus, WebhookTriggerEvents } from '@prisma/client';
|
||||
|
||||
import { getI18nInstance } from '../../client-only/providers/i18n-server';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { jobs } from '../../jobs/client';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
|
||||
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
|
||||
import { mapEnvelopeToWebhookDocumentPayload, ZWebhookDocumentSchema } from '../../types/webhook-payload';
|
||||
@@ -17,7 +12,6 @@ import { isDocumentCompleted } from '../../utils/document';
|
||||
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
|
||||
import { type EnvelopeIdOptions, unsafeBuildEnvelopeIdQuery } from '../../utils/envelope';
|
||||
import { isRecipientEmailValidForSending } from '../../utils/recipients';
|
||||
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';
|
||||
@@ -126,7 +120,7 @@ const handleDocumentOwnerDelete = async ({ envelope, user, requestMetadata }: Ha
|
||||
return;
|
||||
}
|
||||
|
||||
const { branding, emailLanguage, senderEmail, replyToEmail } = await getEmailContext({
|
||||
const { emailLanguage, emailsDisabled } = await getEmailContext({
|
||||
emailType: 'RECIPIENT',
|
||||
source: {
|
||||
type: 'team',
|
||||
@@ -187,50 +181,46 @@ const handleDocumentOwnerDelete = async ({ envelope, user, requestMetadata }: Ha
|
||||
|
||||
const isEnvelopeDeleteEmailEnabled = extractDerivedDocumentEmailSettings(envelope.documentMeta).documentDeleted;
|
||||
|
||||
if (!isEnvelopeDeleteEmailEnabled) {
|
||||
// Skip sending if the email is disabled for this document or the organisation
|
||||
// has email sending disabled entirely.
|
||||
if (!isEnvelopeDeleteEmailEnabled || emailsDisabled) {
|
||||
return deletedEnvelope;
|
||||
}
|
||||
|
||||
// Send cancellation emails to recipients.
|
||||
await Promise.all(
|
||||
envelope.recipients.map(async (recipient) => {
|
||||
if (recipient.sendStatus !== SendStatus.SENT || !isRecipientEmailValidForSending(recipient)) {
|
||||
return;
|
||||
}
|
||||
// Enqueue cancellation emails as a background job. The envelope (and its
|
||||
// documentMeta) is hard-deleted above, so the job can't look it up later —
|
||||
// pass a self-contained payload with the recipients to notify.
|
||||
const recipientsToNotify = envelope.recipients
|
||||
.filter(
|
||||
(recipient) =>
|
||||
recipient.sendStatus === SendStatus.SENT &&
|
||||
recipient.role !== RecipientRole.CC &&
|
||||
isRecipientEmailValidForSending(recipient),
|
||||
)
|
||||
.map((recipient) => ({
|
||||
email: recipient.email,
|
||||
name: recipient.name,
|
||||
}));
|
||||
|
||||
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
|
||||
|
||||
const template = createElement(DocumentCancelTemplate, {
|
||||
if (recipientsToNotify.length > 0) {
|
||||
await jobs.triggerJob({
|
||||
name: 'send.document.deleted.emails',
|
||||
payload: {
|
||||
teamId: envelope.teamId,
|
||||
documentName: envelope.title,
|
||||
inviterName: user.name || undefined,
|
||||
inviterEmail: user.email,
|
||||
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`Document Cancelled`),
|
||||
html,
|
||||
text,
|
||||
});
|
||||
}),
|
||||
);
|
||||
meta: envelope.documentMeta
|
||||
? {
|
||||
emailId: envelope.documentMeta.emailId,
|
||||
emailReplyTo: envelope.documentMeta.emailReplyTo,
|
||||
language: emailLanguage,
|
||||
}
|
||||
: null,
|
||||
recipients: recipientsToNotify,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return deletedEnvelope;
|
||||
};
|
||||
|
||||
@@ -248,7 +248,11 @@ export const findDocuments = async ({
|
||||
eb.or([
|
||||
eb('Envelope.userId', '=', user.id),
|
||||
eb.and([
|
||||
eb('Envelope.status', 'in', [sql.lit(DocumentStatus.COMPLETED), sql.lit(DocumentStatus.PENDING)]),
|
||||
eb('Envelope.status', 'in', [
|
||||
sql.lit(DocumentStatus.COMPLETED),
|
||||
sql.lit(DocumentStatus.PENDING),
|
||||
sql.lit(DocumentStatus.CANCELLED),
|
||||
]),
|
||||
recipientExists(eb, user.email),
|
||||
]),
|
||||
]),
|
||||
@@ -319,6 +323,16 @@ export const findDocuments = async ({
|
||||
]),
|
||||
),
|
||||
)
|
||||
.with(ExtendedDocumentStatus.CANCELLED, () =>
|
||||
qb
|
||||
.where('Envelope.status', '=', sql.lit(ExtendedDocumentStatus.CANCELLED))
|
||||
.where((eb) =>
|
||||
eb.and([
|
||||
personalDeletedFilter(eb),
|
||||
eb.or([eb('Envelope.userId', '=', user.id), recipientExists(eb, user.email)]),
|
||||
]),
|
||||
),
|
||||
)
|
||||
.with(ExtendedDocumentStatus.EXPIRED, () =>
|
||||
qb.where((eb) =>
|
||||
eb.and([
|
||||
@@ -466,6 +480,18 @@ export const findDocuments = async ({
|
||||
return eb.and([teamDeletedFilter(eb), visibilityFilter(eb), eb.or(accessBranches)]);
|
||||
}),
|
||||
)
|
||||
.with(ExtendedDocumentStatus.CANCELLED, () =>
|
||||
qb.where('Envelope.status', '=', sql.lit(ExtendedDocumentStatus.CANCELLED)).where((eb) => {
|
||||
const accessBranches = [eb('Envelope.teamId', '=', teamData.id)];
|
||||
|
||||
if (teamEmail) {
|
||||
accessBranches.push(senderEmailIs(eb, teamEmail));
|
||||
accessBranches.push(recipientExists(eb, teamEmail));
|
||||
}
|
||||
|
||||
return eb.and([teamDeletedFilter(eb), visibilityFilter(eb), eb.or(accessBranches)]);
|
||||
}),
|
||||
)
|
||||
.with(ExtendedDocumentStatus.EXPIRED, () =>
|
||||
qb.where((eb) => {
|
||||
const accessBranches = [eb('Envelope.teamId', '=', teamData.id)];
|
||||
|
||||
@@ -256,6 +256,20 @@ export const getStats = async ({ userId, teamId, period, search = '', folderId,
|
||||
return eb.and([teamDeletedFilter(eb), visibilityFilter(eb), eb.or(accessBranches)]);
|
||||
});
|
||||
|
||||
// CANCELLED: team-owned cancelled + team-email received cancelled docs
|
||||
const cancelledQuery = buildBaseQuery()
|
||||
.where('Envelope.status', '=', sql.lit(DocumentStatus.CANCELLED))
|
||||
.where((eb) => {
|
||||
const accessBranches = [eb('Envelope.teamId', '=', team.id)];
|
||||
|
||||
if (teamEmail) {
|
||||
accessBranches.push(senderEmailIs(eb, teamEmail));
|
||||
accessBranches.push(recipientExists(eb, teamEmail));
|
||||
}
|
||||
|
||||
return eb.and([teamDeletedFilter(eb), visibilityFilter(eb), eb.or(accessBranches)]);
|
||||
});
|
||||
|
||||
// EXPIRED: docs visible to the team/user with at least one expired, unsigned recipient.
|
||||
// Access control mirrors the EXPIRED branch in findDocuments so the count matches the listing.
|
||||
const expiredQuery = buildBaseQuery().where((eb) => {
|
||||
@@ -290,23 +304,25 @@ export const getStats = async ({ userId, teamId, period, search = '', folderId,
|
||||
|
||||
// ─── Execute all counts in parallel ──────────────────────────────────
|
||||
|
||||
const [draft, pending, completed, rejected, expired, inbox] = await Promise.all([
|
||||
const [draft, pending, completed, rejected, cancelled, expired, inbox] = await Promise.all([
|
||||
cappedCount(draftQuery),
|
||||
cappedCount(pendingQuery),
|
||||
cappedCount(completedQuery),
|
||||
cappedCount(rejectedQuery),
|
||||
cappedCount(cancelledQuery),
|
||||
cappedCount(expiredQuery),
|
||||
inboxQuery ? cappedCount(inboxQuery) : Promise.resolve(0),
|
||||
]);
|
||||
|
||||
// `expired` is intentionally excluded from `all` — it overlaps PENDING.
|
||||
const all = Math.min(draft + pending + completed + rejected + inbox, STATS_COUNT_CAP);
|
||||
const all = Math.min(draft + pending + completed + rejected + cancelled + inbox, STATS_COUNT_CAP);
|
||||
|
||||
const stats: Record<ExtendedDocumentStatus, number> = {
|
||||
[ExtendedDocumentStatus.DRAFT]: draft,
|
||||
[ExtendedDocumentStatus.PENDING]: pending,
|
||||
[ExtendedDocumentStatus.COMPLETED]: completed,
|
||||
[ExtendedDocumentStatus.REJECTED]: rejected,
|
||||
[ExtendedDocumentStatus.CANCELLED]: cancelled,
|
||||
[ExtendedDocumentStatus.EXPIRED]: expired,
|
||||
[ExtendedDocumentStatus.INBOX]: inbox,
|
||||
[ExtendedDocumentStatus.ALL]: all,
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
// This is closely related to `reject-document-with-token.ts` but is intentionally
|
||||
// kept as a separate method rather than merged into one. This file focuses on
|
||||
// rejection from an API/programmatic perspective (an authenticated API user acting
|
||||
// on behalf of a recipient), whereas `reject-document-with-token.ts` focuses on it
|
||||
// from a recipient perspective (the recipient rejecting via their token).
|
||||
//
|
||||
// Code changes in one should probably be mirrored to the other, particularly in
|
||||
// relation to the jobs triggered after a rejection.
|
||||
import { jobs } from '@documenso/lib/jobs/client';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { DocumentStatus, EnvelopeType, SigningStatus } from '@prisma/client';
|
||||
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
|
||||
import type { ApiRequestMetadata } from '../../universal/extract-request-metadata';
|
||||
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
|
||||
import { mapSecondaryIdToDocumentId } from '../../utils/envelope';
|
||||
import { assertRecipientNotExpired } from '../../utils/recipients';
|
||||
import { buildTeamWhereQuery } from '../../utils/teams';
|
||||
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
|
||||
|
||||
export type RejectDocumentOnBehalfOfOptions = {
|
||||
/**
|
||||
* The ID of the envelope the recipient belongs to. Required so the caller
|
||||
* targets an explicit envelope/recipient combination rather than resolving the
|
||||
* envelope implicitly from the recipient ID.
|
||||
*/
|
||||
envelopeId: string;
|
||||
recipientId: number;
|
||||
userId: number;
|
||||
teamId: number;
|
||||
reason: string;
|
||||
/**
|
||||
* The email of a team member to attribute the rejection to. Must be a member
|
||||
* of the team. When omitted the rejection is attributed to the API user that
|
||||
* owns the token (`userId`).
|
||||
*
|
||||
* This exists so external applications can elect which team member is acting
|
||||
* on behalf of the recipient, rather than always defaulting to the API user.
|
||||
*/
|
||||
actAsEmail?: string;
|
||||
requestMetadata: ApiRequestMetadata;
|
||||
};
|
||||
|
||||
/**
|
||||
* Reject a document on behalf of a recipient as an authenticated API user.
|
||||
*
|
||||
* This is used to programmatically record a rejection for cases where the
|
||||
* recipient declined to sign outside of the platform (e.g. before ever
|
||||
* reaching it). The rejection is flagged as `isExternal` in the audit log to
|
||||
* distinguish it from a rejection performed by the recipient directly.
|
||||
*
|
||||
* The action can optionally be attributed to a specific team member via
|
||||
* `actAsEmail`; otherwise it is attributed to the API user.
|
||||
*/
|
||||
export async function rejectDocumentOnBehalfOf({
|
||||
envelopeId,
|
||||
recipientId,
|
||||
userId,
|
||||
teamId,
|
||||
reason,
|
||||
actAsEmail,
|
||||
requestMetadata,
|
||||
}: RejectDocumentOnBehalfOfOptions) {
|
||||
// Build the access-controlled envelope query. This enforces team membership
|
||||
// AND document visibility (and owner / team-email access), mirroring the
|
||||
// canonical envelope access checks used across the app.
|
||||
const { envelopeWhereInput } = await getEnvelopeWhereInput({
|
||||
id: { type: 'envelopeId', id: envelopeId },
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
userId,
|
||||
teamId,
|
||||
});
|
||||
|
||||
const recipient = await prisma.recipient.findFirst({
|
||||
where: {
|
||||
id: recipientId,
|
||||
envelope: envelopeWhereInput,
|
||||
},
|
||||
include: {
|
||||
envelope: true,
|
||||
},
|
||||
});
|
||||
|
||||
const envelope = recipient?.envelope;
|
||||
|
||||
if (!recipient || !envelope) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Document or recipient not found',
|
||||
});
|
||||
}
|
||||
|
||||
if (envelope.status !== DocumentStatus.PENDING) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: `Document ${envelope.id} must be pending to reject`,
|
||||
});
|
||||
}
|
||||
|
||||
if (recipient.signingStatus !== SigningStatus.NOT_SIGNED) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: `Recipient ${recipient.id} has already actioned this document`,
|
||||
});
|
||||
}
|
||||
|
||||
assertRecipientNotExpired(recipient);
|
||||
|
||||
// Resolve the user the rejection should be attributed to. When `actAsEmail`
|
||||
// is supplied it must resolve to a member of the team; otherwise the rejection
|
||||
// is attributed to the API user that owns the token.
|
||||
const electedUser = await getValidatedElectedUser({ actAsEmail, teamId });
|
||||
const actingUser = electedUser ?? (await prisma.user.findFirstOrThrow({ where: { id: userId } }));
|
||||
|
||||
// Update the recipient status to rejected and record an external rejection
|
||||
// audit log within the same transaction.
|
||||
const [updatedRecipient] = await prisma.$transaction([
|
||||
prisma.recipient.update({
|
||||
where: {
|
||||
id: recipient.id,
|
||||
},
|
||||
data: {
|
||||
signedAt: new Date(),
|
||||
signingStatus: SigningStatus.REJECTED,
|
||||
rejectionReason: reason,
|
||||
},
|
||||
}),
|
||||
prisma.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
envelopeId: envelope.id,
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_REJECTED,
|
||||
// Always attribute the audit log to a concrete user: the elected team
|
||||
// member when supplied, otherwise the API user that owns the token.
|
||||
user: { id: actingUser.id, email: actingUser.email, name: actingUser.name },
|
||||
metadata: requestMetadata,
|
||||
data: {
|
||||
recipientEmail: recipient.email,
|
||||
recipientName: recipient.name,
|
||||
recipientId: recipient.id,
|
||||
recipientRole: recipient.role,
|
||||
reason,
|
||||
isExternal: true,
|
||||
// Only set when a member was explicitly elected via `actAsEmail`.
|
||||
onBehalfOfUserEmail: electedUser?.email,
|
||||
onBehalfOfUserName: electedUser?.name,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
|
||||
const legacyDocumentId = mapSecondaryIdToDocumentId(envelope.secondaryId);
|
||||
|
||||
// Trigger the seal document job to process the document asynchronously.
|
||||
await jobs.triggerJob({
|
||||
name: 'internal.seal-document',
|
||||
payload: {
|
||||
documentId: legacyDocumentId,
|
||||
requestMetadata: requestMetadata.requestMetadata,
|
||||
},
|
||||
});
|
||||
|
||||
// Send email notifications to the rejecting recipient.
|
||||
await jobs.triggerJob({
|
||||
name: 'send.signing.rejected.emails',
|
||||
payload: {
|
||||
recipientId: recipient.id,
|
||||
documentId: legacyDocumentId,
|
||||
},
|
||||
});
|
||||
|
||||
// Send cancellation emails to other recipients.
|
||||
await jobs.triggerJob({
|
||||
name: 'send.document.cancelled.emails',
|
||||
payload: {
|
||||
documentId: legacyDocumentId,
|
||||
cancellationReason: reason,
|
||||
requestMetadata: requestMetadata.requestMetadata,
|
||||
},
|
||||
});
|
||||
|
||||
return updatedRecipient;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve and validate the team member elected via `actAsEmail`. Returns `null`
|
||||
* when no `actAsEmail` is supplied (the rejection is then attributed to the API
|
||||
* user). Throws when the email does not resolve to a member of the team.
|
||||
*/
|
||||
const getValidatedElectedUser = async ({ actAsEmail, teamId }: { actAsEmail?: string; teamId: number }) => {
|
||||
if (!actAsEmail) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const electedUser = await prisma.user.findFirst({
|
||||
where: {
|
||||
email: actAsEmail,
|
||||
},
|
||||
});
|
||||
|
||||
if (!electedUser) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'The user to act on behalf of must be a member of the team',
|
||||
});
|
||||
}
|
||||
|
||||
const isTeamMember = await prisma.team.findFirst({
|
||||
where: buildTeamWhereQuery({ teamId, userId: electedUser.id }),
|
||||
});
|
||||
|
||||
if (!isTeamMember) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'The user to act on behalf of must be a member of the team',
|
||||
});
|
||||
}
|
||||
|
||||
return electedUser;
|
||||
};
|
||||
@@ -1,3 +1,11 @@
|
||||
// This is closely related to `reject-document-on-behalf-of.ts` but is intentionally
|
||||
// kept as a separate method rather than merged into one. This file focuses on
|
||||
// rejection from a recipient perspective (the recipient rejecting via their token),
|
||||
// whereas `reject-document-on-behalf-of.ts` focuses on it from an API/programmatic
|
||||
// perspective (an authenticated API user acting on behalf of a recipient).
|
||||
//
|
||||
// Code changes in one should probably be mirrored to the other, particularly in
|
||||
// relation to the jobs triggered after a rejection.
|
||||
import { jobs } from '@documenso/lib/jobs/client';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { DocumentStatus, EnvelopeType, SigningStatus } from '@prisma/client';
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { mailer } from '@documenso/email/mailer';
|
||||
import { DocumentInviteEmailTemplate } from '@documenso/email/templates/document-invite';
|
||||
import { resolveExpiresAt } from '@documenso/lib/constants/envelope-expiration';
|
||||
import { RECIPIENT_ROLE_TO_EMAIL_TYPE, RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
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';
|
||||
@@ -14,6 +13,7 @@ import {
|
||||
EnvelopeType,
|
||||
OrganisationType,
|
||||
RecipientRole,
|
||||
SendStatus,
|
||||
SigningStatus,
|
||||
WebhookTriggerEvents,
|
||||
} from '@prisma/client';
|
||||
@@ -27,9 +27,11 @@ import { isDocumentCompleted } from '../../utils/document';
|
||||
import type { EnvelopeIdOptions } from '../../utils/envelope';
|
||||
import { isRecipientEmailValidForSending } from '../../utils/recipients';
|
||||
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
|
||||
import { assertOrgEmailSendAllowed } from '../email/assert-org-email-send-allowed';
|
||||
import { buildEnvelopeEmailHeaders } from '../email/build-envelope-email-headers';
|
||||
import { getEmailContext } from '../email/get-email-context';
|
||||
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
|
||||
import { assertOrganisationRatesAndLimits } from '../rate-limit/assert-organisation-rates-and-limits';
|
||||
import { updateRecipientNextReminder } from '../recipient/update-recipient-next-reminder';
|
||||
import { assertUserNotDisabled } from '../user/assert-user-not-disabled';
|
||||
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||
|
||||
@@ -74,6 +76,15 @@ export const resendDocument = async ({ id, userId, recipients, teamId, requestMe
|
||||
select: {
|
||||
teamEmail: true,
|
||||
name: true,
|
||||
organisation: {
|
||||
select: {
|
||||
organisationClaim: {
|
||||
select: {
|
||||
recipientCount: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -95,7 +106,19 @@ export const resendDocument = async ({ id, userId, recipients, teamId, requestMe
|
||||
throw new Error('Can not send completed document');
|
||||
}
|
||||
|
||||
// Refresh the expiresAt on each resent recipient.
|
||||
// A recipientCount of 0 means unlimited recipients are allowed. Block resending
|
||||
// when the document has more recipients than the organisation is allowed to send
|
||||
// to, mirroring the check in `sendDocument`. This prevents bypassing the limit by
|
||||
// adding recipients to an already-sent document and then resending.
|
||||
const maximumRecipientCount = envelope.team.organisation.organisationClaim.recipientCount;
|
||||
|
||||
if (maximumRecipientCount > 0 && envelope.recipients.length > maximumRecipientCount) {
|
||||
throw new AppError('RECIPIENT_LIMIT_EXCEEDED', {
|
||||
message: `You cannot send a document with more than ${maximumRecipientCount} recipients`,
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const expiresAt = resolveExpiresAt(envelope.documentMeta?.envelopeExpirationPeriod ?? null);
|
||||
|
||||
const recipientsToRemind = envelope.recipients.filter(
|
||||
@@ -105,7 +128,6 @@ export const resendDocument = async ({ id, userId, recipients, teamId, requestMe
|
||||
recipient.role !== RecipientRole.CC,
|
||||
);
|
||||
|
||||
// Extend the expiration deadline for recipients being resent.
|
||||
if (expiresAt && recipientsToRemind.length > 0) {
|
||||
await prisma.recipient.updateMany({
|
||||
where: {
|
||||
@@ -120,6 +142,22 @@ export const resendDocument = async ({ id, userId, recipients, teamId, requestMe
|
||||
});
|
||||
}
|
||||
|
||||
// A manual resend restarts the reminder cycle from scratch, mirroring the
|
||||
// initial send, so a recipient that hit the threshold can be reminded again.
|
||||
const resentAt = new Date();
|
||||
|
||||
await Promise.all(
|
||||
recipientsToRemind.map((recipient) =>
|
||||
updateRecipientNextReminder({
|
||||
recipientId: recipient.id,
|
||||
envelopeId: envelope.id,
|
||||
sentAt: resentAt,
|
||||
lastReminderSentAt: null,
|
||||
resetReminderCount: true,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const isRecipientSigningRequestEmailEnabled = extractDerivedDocumentEmailSettings(
|
||||
envelope.documentMeta,
|
||||
).recipientSigningRequest;
|
||||
@@ -128,15 +166,37 @@ export const resendDocument = async ({ id, userId, recipients, teamId, requestMe
|
||||
return envelope;
|
||||
}
|
||||
|
||||
const { branding, emailLanguage, organisationType, senderEmail, replyToEmail, organisationId } =
|
||||
await getEmailContext({
|
||||
emailType: 'RECIPIENT',
|
||||
source: {
|
||||
type: 'team',
|
||||
teamId: envelope.teamId,
|
||||
},
|
||||
meta: envelope.documentMeta,
|
||||
});
|
||||
const {
|
||||
branding,
|
||||
emailLanguage,
|
||||
organisationType,
|
||||
senderEmail,
|
||||
replyToEmail,
|
||||
organisationId,
|
||||
claims,
|
||||
emailsDisabled,
|
||||
emailTransport,
|
||||
} = await getEmailContext({
|
||||
emailType: 'RECIPIENT',
|
||||
source: {
|
||||
type: 'team',
|
||||
teamId: envelope.teamId,
|
||||
},
|
||||
meta: envelope.documentMeta,
|
||||
});
|
||||
|
||||
// Don't resend any emails if the organisation has email sending disabled.
|
||||
if (user.disabled || emailsDisabled) {
|
||||
return envelope;
|
||||
}
|
||||
|
||||
// Assert that there is enough quota to send the emails.
|
||||
await assertOrganisationRatesAndLimits({
|
||||
organisationId,
|
||||
organisationClaim: claims,
|
||||
count: recipientsToRemind.length,
|
||||
type: 'email',
|
||||
});
|
||||
|
||||
await Promise.all(
|
||||
recipientsToRemind.map(async (recipient) => {
|
||||
@@ -180,6 +240,7 @@ export const resendDocument = async ({ id, userId, recipients, teamId, requestMe
|
||||
|
||||
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
|
||||
const signDocumentLink = `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`;
|
||||
const reportUrl = `${NEXT_PUBLIC_WEBAPP_URL()}/report/${recipient.token}`;
|
||||
|
||||
const template = createElement(DocumentInviteEmailTemplate, {
|
||||
documentName: envelope.title,
|
||||
@@ -195,6 +256,7 @@ export const resendDocument = async ({ id, userId, recipients, teamId, requestMe
|
||||
selfSigner,
|
||||
organisationType,
|
||||
teamName: envelope.team?.name,
|
||||
reportUrl,
|
||||
});
|
||||
|
||||
const [html, text] = await Promise.all([
|
||||
@@ -209,18 +271,9 @@ export const resendDocument = async ({ id, userId, recipients, teamId, requestMe
|
||||
}),
|
||||
]);
|
||||
|
||||
const sendCheck = await assertOrgEmailSendAllowed({ organisationId });
|
||||
|
||||
if (!sendCheck.allowed) {
|
||||
throw new AppError(AppErrorCode.TOO_MANY_REQUESTS, {
|
||||
message: 'Organisation email send rate limit exceeded',
|
||||
userMessage: 'Email send rate limit reached. Please try again in a few minutes.',
|
||||
});
|
||||
}
|
||||
|
||||
// Send email outside any transaction to avoid holding a connection
|
||||
// open during network I/O.
|
||||
await mailer.sendMail({
|
||||
await emailTransport.sendMail({
|
||||
to: {
|
||||
address: email,
|
||||
name,
|
||||
@@ -232,6 +285,23 @@ export const resendDocument = async ({ id, userId, recipients, teamId, requestMe
|
||||
: emailSubject,
|
||||
html,
|
||||
text,
|
||||
headers: buildEnvelopeEmailHeaders({
|
||||
userId: envelope.userId,
|
||||
envelopeId: envelope.id,
|
||||
teamId: envelope.teamId,
|
||||
}),
|
||||
});
|
||||
|
||||
// Mark the recipient as sent if they were not already sent.
|
||||
await prisma.recipient.updateMany({
|
||||
where: {
|
||||
id: recipient.id,
|
||||
sendStatus: SendStatus.NOT_SENT,
|
||||
},
|
||||
data: {
|
||||
sendStatus: SendStatus.SENT,
|
||||
sentAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.documentAuditLog.create({
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { mailer } from '@documenso/email/mailer';
|
||||
import { DocumentSuperDeleteEmailTemplate } from '@documenso/email/templates/document-super-delete';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
@@ -46,7 +45,7 @@ export const sendDeleteEmail = async ({ envelopeId, reason }: SendDeleteEmailOpt
|
||||
return;
|
||||
}
|
||||
|
||||
const { branding, emailLanguage, senderEmail } = await getEmailContext({
|
||||
const { branding, emailLanguage, senderEmail, emailTransport } = await getEmailContext({
|
||||
emailType: 'INTERNAL',
|
||||
source: {
|
||||
type: 'team',
|
||||
@@ -76,7 +75,7 @@ export const sendDeleteEmail = async ({ envelopeId, reason }: SendDeleteEmailOpt
|
||||
|
||||
const i18n = await getI18nInstance(emailLanguage);
|
||||
|
||||
await mailer.sendMail({
|
||||
await emailTransport.sendMail({
|
||||
to: {
|
||||
address: email,
|
||||
name: name || '',
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { materializeTspAnchorsForEnvelope } from '@documenso/ee/server-only/signing/csc/materialize-anchors';
|
||||
import { resolveExpiresAt } from '@documenso/lib/constants/envelope-expiration';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
||||
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||
@@ -29,6 +30,7 @@ import {
|
||||
ZRadioFieldMeta,
|
||||
ZTextFieldMeta,
|
||||
} from '../../types/field-meta';
|
||||
import { isTspEnvelope } from '../../types/signature-level';
|
||||
import { mapEnvelopeToWebhookDocumentPayload, ZWebhookDocumentSchema } from '../../types/webhook-payload';
|
||||
import { getFileServerSide } from '../../universal/upload/get-file.server';
|
||||
import { putNormalizedPdfFileServerSide } from '../../universal/upload/put-file.server';
|
||||
@@ -84,6 +86,19 @@ export const sendDocument = async ({ id, userId, teamId, sendEmail, requestMetad
|
||||
},
|
||||
},
|
||||
},
|
||||
team: {
|
||||
select: {
|
||||
organisation: {
|
||||
select: {
|
||||
organisationClaim: {
|
||||
select: {
|
||||
recipientCount: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -95,13 +110,42 @@ export const sendDocument = async ({ id, userId, teamId, sendEmail, requestMetad
|
||||
throw new Error('Document has no recipients');
|
||||
}
|
||||
|
||||
// A recipientCount of 0 means unlimited recipients are allowed.
|
||||
const maximumRecipientCount = envelope.team.organisation.organisationClaim.recipientCount;
|
||||
|
||||
if (maximumRecipientCount > 0 && envelope.recipients.length > maximumRecipientCount) {
|
||||
throw new AppError('RECIPIENT_LIMIT_EXCEEDED', {
|
||||
message: `You cannot send a document with more than ${maximumRecipientCount} recipients`,
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
if (isDocumentCompleted(envelope.status)) {
|
||||
throw new Error('Can not send completed document');
|
||||
}
|
||||
|
||||
const legacyDocumentId = mapSecondaryIdToDocumentId(envelope.secondaryId);
|
||||
|
||||
const signingOrder = envelope.documentMeta?.signingOrder || DocumentSigningOrder.PARALLEL;
|
||||
let signingOrder = envelope.documentMeta?.signingOrder || DocumentSigningOrder.PARALLEL;
|
||||
|
||||
if (isTspEnvelope(envelope) && signingOrder === DocumentSigningOrder.PARALLEL && envelope.documentMeta) {
|
||||
console.warn(
|
||||
`[CSC] Coercing signingOrder=PARALLEL → SEQUENTIAL for ${envelope.signatureLevel} envelope ${envelope.id} at send time. The schema-layer guard should have caught this earlier.`,
|
||||
);
|
||||
|
||||
await prisma.documentMeta.update({
|
||||
where: {
|
||||
id: envelope.documentMeta.id,
|
||||
},
|
||||
data: {
|
||||
signingOrder: DocumentSigningOrder.SEQUENTIAL,
|
||||
},
|
||||
});
|
||||
|
||||
signingOrder = DocumentSigningOrder.SEQUENTIAL;
|
||||
|
||||
envelope.documentMeta.signingOrder = DocumentSigningOrder.SEQUENTIAL;
|
||||
}
|
||||
|
||||
let recipientsToNotify = envelope.recipients;
|
||||
|
||||
@@ -116,7 +160,7 @@ export const sendDocument = async ({ id, userId, teamId, sendEmail, requestMetad
|
||||
throw new Error('Missing envelope items');
|
||||
}
|
||||
|
||||
if (envelope.formValues) {
|
||||
if (envelope.formValues && envelope.status === DocumentStatus.DRAFT) {
|
||||
await Promise.all(
|
||||
envelope.envelopeItems.map(async (envelopeItem) => {
|
||||
await injectFormValuesIntoDocument(envelope, envelopeItem);
|
||||
@@ -202,6 +246,12 @@ export const sendDocument = async ({ id, userId, teamId, sendEmail, requestMetad
|
||||
}
|
||||
}
|
||||
|
||||
if (isTspEnvelope(envelope) && envelope.status === DocumentStatus.DRAFT) {
|
||||
await materializeTspAnchorsForEnvelope({
|
||||
envelopeId: envelope.id,
|
||||
});
|
||||
}
|
||||
|
||||
const updatedEnvelope = await prisma.$transaction(async (tx) => {
|
||||
if (envelope.status === DocumentStatus.DRAFT) {
|
||||
await tx.documentAuditLog.create({
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
import { mailer } from '@documenso/email/mailer';
|
||||
import { DocumentPendingEmailTemplate } from '@documenso/email/templates/document-pending';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { EnvelopeType } from '@prisma/client';
|
||||
import { createElement } from 'react';
|
||||
|
||||
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 { isRecipientEmailValidForSending } from '../../utils/recipients';
|
||||
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
|
||||
import { getEmailContext } from '../email/get-email-context';
|
||||
|
||||
export interface SendPendingEmailOptions {
|
||||
id: EnvelopeIdOptions;
|
||||
recipientId: number;
|
||||
}
|
||||
|
||||
export const sendPendingEmail = async ({ id, recipientId }: SendPendingEmailOptions) => {
|
||||
const envelope = await prisma.envelope.findFirst({
|
||||
where: {
|
||||
...unsafeBuildEnvelopeIdQuery(id, EnvelopeType.DOCUMENT),
|
||||
recipients: {
|
||||
some: {
|
||||
id: recipientId,
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
recipients: {
|
||||
where: {
|
||||
id: recipientId,
|
||||
},
|
||||
},
|
||||
documentMeta: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!envelope) {
|
||||
throw new Error('Document not found');
|
||||
}
|
||||
|
||||
if (envelope.recipients.length === 0) {
|
||||
throw new Error('Document has no recipients');
|
||||
}
|
||||
|
||||
const { branding, emailLanguage, senderEmail, replyToEmail } = await getEmailContext({
|
||||
emailType: 'RECIPIENT',
|
||||
source: {
|
||||
type: 'team',
|
||||
teamId: envelope.teamId,
|
||||
},
|
||||
meta: envelope.documentMeta,
|
||||
});
|
||||
|
||||
const isDocumentPendingEmailEnabled = extractDerivedDocumentEmailSettings(envelope.documentMeta).documentPending;
|
||||
|
||||
if (!isDocumentPendingEmailEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [recipient] = envelope.recipients;
|
||||
|
||||
const { email, name } = recipient;
|
||||
|
||||
// Skip sending email if recipient has no email address
|
||||
if (!isRecipientEmailValidForSending(recipient)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
|
||||
|
||||
const template = createElement(DocumentPendingEmailTemplate, {
|
||||
documentName: envelope.title,
|
||||
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: email,
|
||||
name,
|
||||
},
|
||||
from: senderEmail,
|
||||
replyTo: replyToEmail,
|
||||
subject: i18n._(msg`Waiting for others to complete signing.`),
|
||||
html,
|
||||
text,
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user