mirror of
https://github.com/documenso/documenso.git
synced 2026-06-23 04:42:09 +10:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f2525ae95b | |||
| 2f24a8eab2 | |||
| d9b7722325 | |||
| 783123f72b | |||
| e8ed1c3d99 |
@@ -206,6 +206,11 @@ export const EnvelopeDistributeDialog = ({
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// Default the distribution method tab to the envelope's configured setting.
|
||||
if (isOpen && envelope.documentMeta) {
|
||||
setValue('meta.distributionMethod', envelope.documentMeta.distributionMethod);
|
||||
}
|
||||
|
||||
// Resync the whole envelope if the envelope is mid saving.
|
||||
if (isOpen && (isAutosaving || autosaveError)) {
|
||||
void handleSync();
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
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 { SendStatus, SigningStatus } from '@documenso/prisma/client';
|
||||
import { seedPendingDocument } from '@documenso/prisma/seed/documents';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import type { Team, User } from '@prisma/client';
|
||||
|
||||
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
|
||||
const baseUrl = `${WEBAPP_BASE_URL}/api/v2-beta`;
|
||||
|
||||
test.describe.configure({
|
||||
mode: 'parallel',
|
||||
});
|
||||
|
||||
test.describe('Redistribute updates recipient send status', () => {
|
||||
let user: User, team: Team, token: string;
|
||||
|
||||
test.beforeEach(async () => {
|
||||
({ user, team } = await seedUser());
|
||||
({ token } = await createApiToken({
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
tokenName: 'test',
|
||||
expiresIn: null,
|
||||
}));
|
||||
});
|
||||
|
||||
test('marks a NOT_SENT signer as SENT after a successful resend', async ({ request }) => {
|
||||
const document = await seedPendingDocument(user, team.id, ['recipient@test.documenso.com']);
|
||||
|
||||
const [recipient] = document.recipients;
|
||||
|
||||
// Simulate a recipient that is stuck at NOT_SENT on a pending document
|
||||
// (e.g. the initial send did not dispatch an email for them).
|
||||
await prisma.recipient.update({
|
||||
where: { id: recipient.id },
|
||||
data: {
|
||||
sendStatus: SendStatus.NOT_SENT,
|
||||
signingStatus: SigningStatus.NOT_SIGNED,
|
||||
sentAt: null,
|
||||
},
|
||||
});
|
||||
|
||||
const res = await request.post(`${baseUrl}/document/redistribute`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
data: {
|
||||
documentId: mapSecondaryIdToDocumentId(document.secondaryId),
|
||||
recipients: [recipient.id],
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.ok(), `redistribute should succeed: ${await res.text()}`).toBeTruthy();
|
||||
|
||||
const updatedRecipient = await prisma.recipient.findFirstOrThrow({
|
||||
where: { id: recipient.id },
|
||||
});
|
||||
|
||||
expect(updatedRecipient.sendStatus).toBe(SendStatus.SENT);
|
||||
expect(updatedRecipient.sentAt).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,260 @@
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { DocumentVisibility, SigningStatus, TeamMemberRole } from '@documenso/prisma/client';
|
||||
import { seedPendingDocument } from '@documenso/prisma/seed/documents';
|
||||
import { seedTeam, seedTeamMember } from '@documenso/prisma/seed/teams';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import type { TRejectEnvelopeRecipientOnBehalfOfRequest } from '@documenso/trpc/server/envelope-router/envelope-recipients/reject-envelope-recipient-on-behalf-of.types';
|
||||
import { type APIRequestContext, expect, test } from '@playwright/test';
|
||||
import type { Team, User } from '@prisma/client';
|
||||
|
||||
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
|
||||
const baseUrl = `${WEBAPP_BASE_URL}/api/v2-beta`;
|
||||
|
||||
test.describe.configure({
|
||||
mode: 'parallel',
|
||||
});
|
||||
|
||||
const rejectRecipient = (
|
||||
request: APIRequestContext,
|
||||
authToken: string,
|
||||
envelopeId: string,
|
||||
recipientId: number,
|
||||
reason: string,
|
||||
actAsEmail?: string,
|
||||
) => {
|
||||
return request.post(`${baseUrl}/envelope/recipient/${recipientId}/reject`, {
|
||||
headers: { Authorization: `Bearer ${authToken}`, 'Content-Type': 'application/json' },
|
||||
data: {
|
||||
envelopeId,
|
||||
recipientId,
|
||||
reason,
|
||||
actAsEmail,
|
||||
} satisfies TRejectEnvelopeRecipientOnBehalfOfRequest,
|
||||
});
|
||||
};
|
||||
|
||||
test.describe('Reject recipient on behalf of', () => {
|
||||
let user: User;
|
||||
let team: Team;
|
||||
let token: string;
|
||||
|
||||
test.beforeEach(async () => {
|
||||
({ user, team } = await seedUser());
|
||||
({ token } = await createApiToken({
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
tokenName: 'test-reject-recipient',
|
||||
expiresIn: null,
|
||||
}));
|
||||
});
|
||||
|
||||
test('should reject a recipient and record an external rejection audit log', async ({ request }) => {
|
||||
const envelope = await seedPendingDocument(user, team.id, ['recipient@test.documenso.com']);
|
||||
const recipient = envelope.recipients[0];
|
||||
|
||||
const res = await rejectRecipient(request, token, envelope.id, recipient.id, 'Declined out of band');
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
expect(res.status()).toBe(200);
|
||||
|
||||
const updatedRecipient = await prisma.recipient.findUniqueOrThrow({
|
||||
where: { id: recipient.id },
|
||||
});
|
||||
|
||||
expect(updatedRecipient.signingStatus).toBe(SigningStatus.REJECTED);
|
||||
expect(updatedRecipient.rejectionReason).toBe('Declined out of band');
|
||||
|
||||
const auditLog = await prisma.documentAuditLog.findFirst({
|
||||
where: {
|
||||
envelopeId: envelope.id,
|
||||
type: 'DOCUMENT_RECIPIENT_REJECTED',
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
expect(auditLog).not.toBeNull();
|
||||
|
||||
const auditData = auditLog!.data as Record<string, unknown>;
|
||||
|
||||
expect(auditData.recipientId).toBe(recipient.id);
|
||||
expect(auditData.recipientEmail).toBe(recipient.email);
|
||||
expect(auditData.reason).toBe('Declined out of band');
|
||||
expect(auditData.isExternal).toBe(true);
|
||||
|
||||
// No actAsEmail supplied - the rejection defaults to the API user.
|
||||
expect(auditLog!.userId).toBe(user.id);
|
||||
expect(auditLog!.email).toBe(user.email);
|
||||
expect(auditData.onBehalfOfUserEmail).toBeUndefined();
|
||||
});
|
||||
|
||||
test('should attribute the rejection to the elected team member when actAsEmail is supplied', async ({ request }) => {
|
||||
const member = await seedTeamMember({ teamId: team.id });
|
||||
|
||||
const envelope = await seedPendingDocument(user, team.id, ['recipient@test.documenso.com']);
|
||||
const recipient = envelope.recipients[0];
|
||||
|
||||
const res = await rejectRecipient(request, token, envelope.id, recipient.id, 'Declined out of band', member.email);
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
expect(res.status()).toBe(200);
|
||||
|
||||
const auditLog = await prisma.documentAuditLog.findFirstOrThrow({
|
||||
where: {
|
||||
envelopeId: envelope.id,
|
||||
type: 'DOCUMENT_RECIPIENT_REJECTED',
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
// The audit log actor must be the elected member, not the API user.
|
||||
expect(auditLog.userId).toBe(member.id);
|
||||
expect(auditLog.email).toBe(member.email);
|
||||
|
||||
const auditData = auditLog.data as Record<string, unknown>;
|
||||
|
||||
expect(auditData.isExternal).toBe(true);
|
||||
expect(auditData.onBehalfOfUserEmail).toBe(member.email);
|
||||
});
|
||||
|
||||
test('should reject when actAsEmail is not a member of the team', async ({ request }) => {
|
||||
// A user that exists but belongs to a different team.
|
||||
const { user: outsider } = await seedUser();
|
||||
|
||||
const envelope = await seedPendingDocument(user, team.id, ['recipient@test.documenso.com']);
|
||||
const recipient = envelope.recipients[0];
|
||||
|
||||
const res = await rejectRecipient(
|
||||
request,
|
||||
token,
|
||||
envelope.id,
|
||||
recipient.id,
|
||||
'Declined out of band',
|
||||
outsider.email,
|
||||
);
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(401);
|
||||
|
||||
// The recipient must remain untouched.
|
||||
const untouchedRecipient = await prisma.recipient.findUniqueOrThrow({
|
||||
where: { id: recipient.id },
|
||||
});
|
||||
|
||||
expect(untouchedRecipient.signingStatus).toBe(SigningStatus.NOT_SIGNED);
|
||||
expect(untouchedRecipient.rejectionReason).toBeNull();
|
||||
});
|
||||
|
||||
test('should deny rejecting a recipient that has already actioned the document', async ({ request }) => {
|
||||
const envelope = await seedPendingDocument(user, team.id, ['recipient@test.documenso.com']);
|
||||
const recipient = envelope.recipients[0];
|
||||
|
||||
// Reject once - succeeds.
|
||||
const firstRes = await rejectRecipient(request, token, envelope.id, recipient.id, 'First rejection');
|
||||
expect(firstRes.ok()).toBeTruthy();
|
||||
|
||||
// Reject again - the recipient is no longer NOT_SIGNED.
|
||||
const secondRes = await rejectRecipient(request, token, envelope.id, recipient.id, 'Second rejection');
|
||||
|
||||
expect(secondRes.ok()).toBeFalsy();
|
||||
expect(secondRes.status()).toBe(400);
|
||||
|
||||
// The original rejection reason must remain unchanged.
|
||||
const updatedRecipient = await prisma.recipient.findUniqueOrThrow({
|
||||
where: { id: recipient.id },
|
||||
});
|
||||
|
||||
expect(updatedRecipient.rejectionReason).toBe('First rejection');
|
||||
});
|
||||
|
||||
test('should not allow rejecting a recipient in another team', async ({ request }) => {
|
||||
// Seed a separate team/user that owns the document.
|
||||
const { user: otherUser, team: otherTeam } = await seedUser();
|
||||
|
||||
const envelope = await seedPendingDocument(otherUser, otherTeam.id, ['recipient@test.documenso.com']);
|
||||
const recipient = envelope.recipients[0];
|
||||
|
||||
// Use the original team's token - it must not be able to reject.
|
||||
const res = await rejectRecipient(request, token, envelope.id, recipient.id, 'Should not work');
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(404);
|
||||
|
||||
// The recipient must remain untouched.
|
||||
const untouchedRecipient = await prisma.recipient.findUniqueOrThrow({
|
||||
where: { id: recipient.id },
|
||||
});
|
||||
|
||||
expect(untouchedRecipient.signingStatus).toBe(SigningStatus.NOT_SIGNED);
|
||||
expect(untouchedRecipient.rejectionReason).toBeNull();
|
||||
});
|
||||
|
||||
test('should return 404 for a non-existent recipient', async ({ request }) => {
|
||||
const envelope = await seedPendingDocument(user, team.id, ['recipient@test.documenso.com']);
|
||||
|
||||
const res = await rejectRecipient(request, token, envelope.id, 999999999, 'No such recipient');
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(404);
|
||||
});
|
||||
|
||||
test('should return 404 when the recipient does not belong to the supplied envelope', async ({ request }) => {
|
||||
const targetEnvelope = await seedPendingDocument(user, team.id, ['recipient@test.documenso.com']);
|
||||
const otherEnvelope = await seedPendingDocument(user, team.id, ['other-recipient@test.documenso.com']);
|
||||
|
||||
const recipient = targetEnvelope.recipients[0];
|
||||
|
||||
// Valid recipient ID, but paired with the wrong envelope ID.
|
||||
const res = await rejectRecipient(request, token, otherEnvelope.id, recipient.id, 'Mismatched envelope');
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(404);
|
||||
|
||||
// The recipient must remain untouched.
|
||||
const untouchedRecipient = await prisma.recipient.findUniqueOrThrow({
|
||||
where: { id: recipient.id },
|
||||
});
|
||||
|
||||
expect(untouchedRecipient.signingStatus).toBe(SigningStatus.NOT_SIGNED);
|
||||
expect(untouchedRecipient.rejectionReason).toBeNull();
|
||||
});
|
||||
|
||||
test('should enforce document visibility: manager cannot reject on an ADMIN-only document', async ({ request }) => {
|
||||
// The API token belongs to a MANAGER, who cannot see ADMIN-visibility docs.
|
||||
const { team: visTeam, owner } = await seedTeam();
|
||||
const manager = await seedTeamMember({ teamId: visTeam.id, role: TeamMemberRole.MANAGER });
|
||||
|
||||
const { token: managerToken } = await createApiToken({
|
||||
userId: manager.id,
|
||||
teamId: visTeam.id,
|
||||
tokenName: 'manager-reject-token',
|
||||
expiresIn: null,
|
||||
});
|
||||
|
||||
// ADMIN-visibility document owned by the team owner.
|
||||
const envelope = await seedPendingDocument(owner, visTeam.id, ['recipient@test.documenso.com'], {
|
||||
createDocumentOptions: { visibility: DocumentVisibility.ADMIN },
|
||||
});
|
||||
const recipient = envelope.recipients[0];
|
||||
|
||||
const res = await rejectRecipient(
|
||||
request,
|
||||
managerToken,
|
||||
envelope.id,
|
||||
recipient.id,
|
||||
'Should be hidden by visibility',
|
||||
);
|
||||
|
||||
// Visibility failure surfaces as not-found, matching the canonical checks.
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(404);
|
||||
|
||||
const untouchedRecipient = await prisma.recipient.findUniqueOrThrow({
|
||||
where: { id: recipient.id },
|
||||
});
|
||||
|
||||
expect(untouchedRecipient.signingStatus).toBe(SigningStatus.NOT_SIGNED);
|
||||
expect(untouchedRecipient.rejectionReason).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -31,9 +31,13 @@ export const loadRecipientBrandingByTeamId = async ({
|
||||
billingEnabled ? getOrganisationClaimByTeamId({ teamId }).catch(() => null) : Promise.resolve(null),
|
||||
]);
|
||||
|
||||
const allowCustomBranding = !billingEnabled || claim?.flags?.embedSigningWhiteLabel === true;
|
||||
let allowCustomBranding = !billingEnabled || claim?.flags?.embedSigningWhiteLabel === true;
|
||||
const hidePoweredBy = !billingEnabled || claim?.flags?.hidePoweredBy === true;
|
||||
|
||||
if (!settings.brandingEnabled) {
|
||||
allowCustomBranding = false;
|
||||
}
|
||||
|
||||
if (!allowCustomBranding) {
|
||||
return {
|
||||
allowCustomBranding: false,
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
EnvelopeType,
|
||||
OrganisationType,
|
||||
RecipientRole,
|
||||
SendStatus,
|
||||
SigningStatus,
|
||||
WebhookTriggerEvents,
|
||||
} from '@prisma/client';
|
||||
@@ -276,6 +277,18 @@ export const resendDocument = async ({ id, userId, recipients, teamId, requestMe
|
||||
}),
|
||||
});
|
||||
|
||||
// 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({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -560,12 +560,24 @@ export const ZDocumentAuditLogEventDocumentRecipientCompleteSchema = z.object({
|
||||
});
|
||||
|
||||
/**
|
||||
* Event: Document recipient completed the document (the recipient has fully actioned and completed their required steps for the document).
|
||||
* Event: Document recipient rejected the document.
|
||||
*/
|
||||
export const ZDocumentAuditLogEventDocumentRecipientRejectedSchema = z.object({
|
||||
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_REJECTED),
|
||||
data: ZBaseRecipientDataSchema.extend({
|
||||
reason: z.string(),
|
||||
/**
|
||||
* Whether the rejection was recorded externally on behalf of the recipient
|
||||
* via the API, rather than by the recipient directly on the platform.
|
||||
*/
|
||||
isExternal: z.boolean().optional(),
|
||||
/**
|
||||
* The team member the external rejection was recorded on behalf of, when
|
||||
* the API caller elected a specific member to attribute the action to.
|
||||
* Absent when the rejection is attributed to the API user/token itself.
|
||||
*/
|
||||
onBehalfOfUserEmail: z.string().optional(),
|
||||
onBehalfOfUserName: z.string().nullable().optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -509,11 +509,31 @@ export const formatDocumentAuditLogAction = (i18n: I18n, auditLog: TDocumentAudi
|
||||
user: msg`${user} completed their task`,
|
||||
}));
|
||||
})
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_REJECTED }, () => ({
|
||||
anonymous: msg`Recipient rejected the document`,
|
||||
you: msg`You rejected the document`,
|
||||
user: msg`${user} rejected the document`,
|
||||
}))
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_REJECTED }, ({ data }) => {
|
||||
if (data.isExternal) {
|
||||
const onBehalfOf = data.onBehalfOfUserName || data.onBehalfOfUserEmail;
|
||||
|
||||
if (onBehalfOf) {
|
||||
return {
|
||||
anonymous: msg`The document was rejected externally by ${onBehalfOf} on behalf of the recipient`,
|
||||
you: msg`The document was rejected externally by ${onBehalfOf} on behalf of the recipient`,
|
||||
user: msg`The document was rejected externally by ${onBehalfOf} on behalf of ${user}`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
anonymous: msg`Recipient rejected the document externally`,
|
||||
you: msg`The document was rejected externally on behalf of the recipient`,
|
||||
user: msg`The document was rejected externally on behalf of ${user}`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
anonymous: msg`Recipient rejected the document`,
|
||||
you: msg`You rejected the document`,
|
||||
user: msg`${user} rejected the document`,
|
||||
};
|
||||
})
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_ACCESS_AUTH_2FA_REQUESTED }, () => ({
|
||||
anonymous: msg`Recipient requested a 2FA token for the document`,
|
||||
you: msg`You requested a 2FA token for the document`,
|
||||
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { rejectDocumentOnBehalfOf } from '@documenso/lib/server-only/document/reject-document-on-behalf-of';
|
||||
import { getEnvelopeWhereInput } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { EnvelopeType } from '@prisma/client';
|
||||
|
||||
import { authenticatedProcedure } from '../../trpc';
|
||||
import {
|
||||
rejectEnvelopeRecipientOnBehalfOfMeta,
|
||||
ZRejectEnvelopeRecipientOnBehalfOfRequestSchema,
|
||||
ZRejectEnvelopeRecipientOnBehalfOfResponseSchema,
|
||||
} from './reject-envelope-recipient-on-behalf-of.types';
|
||||
|
||||
export const rejectEnvelopeRecipientOnBehalfOfRoute = authenticatedProcedure
|
||||
.meta(rejectEnvelopeRecipientOnBehalfOfMeta)
|
||||
.input(ZRejectEnvelopeRecipientOnBehalfOfRequestSchema)
|
||||
.output(ZRejectEnvelopeRecipientOnBehalfOfResponseSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { teamId, user } = ctx;
|
||||
const { envelopeId, recipientId, reason, actAsEmail } = input;
|
||||
|
||||
ctx.logger.info({
|
||||
input: {
|
||||
envelopeId,
|
||||
recipientId,
|
||||
},
|
||||
});
|
||||
|
||||
// This is an external-only action: it must only be reachable through the
|
||||
// public API, never the internal app TRPC handler.
|
||||
if (ctx.metadata.source !== 'apiV2') {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'This route is only accessible via the public API',
|
||||
});
|
||||
}
|
||||
|
||||
await rejectDocumentOnBehalfOf({
|
||||
envelopeId,
|
||||
recipientId,
|
||||
userId: user.id,
|
||||
teamId,
|
||||
reason,
|
||||
actAsEmail,
|
||||
requestMetadata: ctx.metadata,
|
||||
});
|
||||
|
||||
const { envelopeWhereInput } = await getEnvelopeWhereInput({
|
||||
id: { type: 'envelopeId', id: envelopeId },
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
userId: user.id,
|
||||
teamId,
|
||||
});
|
||||
|
||||
const recipient = await prisma.recipient.findFirstOrThrow({
|
||||
where: {
|
||||
id: recipientId,
|
||||
envelope: envelopeWhereInput,
|
||||
},
|
||||
include: {
|
||||
fields: true,
|
||||
},
|
||||
});
|
||||
|
||||
return recipient;
|
||||
});
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
import { ZEnvelopeRecipientSchema } from '@documenso/lib/types/recipient';
|
||||
import { zEmail } from '@documenso/lib/utils/zod';
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { TrpcRouteMeta } from '../../trpc';
|
||||
|
||||
export const rejectEnvelopeRecipientOnBehalfOfMeta: TrpcRouteMeta = {
|
||||
openapi: {
|
||||
method: 'POST',
|
||||
path: '/envelope/recipient/{recipientId}/reject',
|
||||
summary: 'Reject envelope recipient on behalf of',
|
||||
description:
|
||||
'Records a rejection on behalf of a recipient. Use this when a recipient has declined to ' +
|
||||
'sign outside of the platform. The rejection is flagged as external in the document audit ' +
|
||||
'log. By default the action is attributed to the API user; supply `actAsEmail` to attribute ' +
|
||||
'it to a specific team member.',
|
||||
tags: ['Envelope Recipients'],
|
||||
},
|
||||
};
|
||||
|
||||
export const ZRejectEnvelopeRecipientOnBehalfOfRequestSchema = z.object({
|
||||
envelopeId: z.string().describe('The ID of the envelope the recipient belongs to.'),
|
||||
recipientId: z.number().describe('The ID of the recipient to reject the document on behalf of.'),
|
||||
reason: z.string().min(1).describe('The reason the recipient rejected the document.'),
|
||||
actAsEmail: zEmail()
|
||||
.optional()
|
||||
.describe('The email of the team member to attribute the rejection to. Defaults to the API user when omitted.'),
|
||||
});
|
||||
|
||||
export const ZRejectEnvelopeRecipientOnBehalfOfResponseSchema = ZEnvelopeRecipientSchema;
|
||||
|
||||
export type TRejectEnvelopeRecipientOnBehalfOfRequest = z.infer<typeof ZRejectEnvelopeRecipientOnBehalfOfRequestSchema>;
|
||||
export type TRejectEnvelopeRecipientOnBehalfOfResponse = z.infer<
|
||||
typeof ZRejectEnvelopeRecipientOnBehalfOfResponseSchema
|
||||
>;
|
||||
@@ -22,6 +22,7 @@ import { updateEnvelopeFieldsRoute } from './envelope-fields/update-envelope-fie
|
||||
import { createEnvelopeRecipientsRoute } from './envelope-recipients/create-envelope-recipients';
|
||||
import { deleteEnvelopeRecipientRoute } from './envelope-recipients/delete-envelope-recipient';
|
||||
import { getEnvelopeRecipientRoute } from './envelope-recipients/get-envelope-recipient';
|
||||
import { rejectEnvelopeRecipientOnBehalfOfRoute } from './envelope-recipients/reject-envelope-recipient-on-behalf-of';
|
||||
import { reportRecipientRoute } from './envelope-recipients/report-recipient';
|
||||
import { updateEnvelopeRecipientsRoute } from './envelope-recipients/update-envelope-recipients';
|
||||
import { findEnvelopeAuditLogsRoute } from './find-envelope-audit-logs';
|
||||
@@ -70,6 +71,7 @@ export const envelopeRouter = router({
|
||||
delete: deleteEnvelopeRecipientRoute,
|
||||
set: setEnvelopeRecipientsRoute,
|
||||
report: reportRecipientRoute,
|
||||
rejectOnBehalfOf: rejectEnvelopeRecipientOnBehalfOfRoute,
|
||||
},
|
||||
field: {
|
||||
get: getEnvelopeFieldRoute,
|
||||
|
||||
Reference in New Issue
Block a user