chore: merge main, resolve biome formatting conflicts

Merge origin/main into feat/external-2fa-codes. Resolve formatting
conflicts caused by biome rollout; preserve both feature streams:
PR's external 2FA token + signing-session 2FA proof additions plus
main's RateLimit/RecipientExpired/signingReminders/date-auto-insert.

In complete-document-with-token.ts, drop the duplicate early
field-fetching block introduced when main moved that logic later
with date auto-insert support; keep the EXTERNAL_TWO_FACTOR_AUTH
check using derivedRecipientActionAuth.
This commit is contained in:
ephraimduncan
2026-05-12 11:46:11 +00:00
parent 9194884fbe
commit 138d663c25
1959 changed files with 93488 additions and 47038 deletions
@@ -1,14 +1,11 @@
import { EnvelopeType, RecipientRole } from '@prisma/client';
import { SendStatus, SigningStatus } from '@prisma/client';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { TRecipientAccessAuthTypes } from '@documenso/lib/types/document-auth';
import { type TRecipientActionAuthTypes } from '@documenso/lib/types/document-auth';
import type { TRecipientAccessAuthTypes, TRecipientActionAuthTypes } 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 } from '@documenso/lib/utils/document-audit-logs';
import { createRecipientAuthOptions } from '@documenso/lib/utils/document-auth';
import { prisma } from '@documenso/prisma';
import { EnvelopeType, RecipientRole, SendStatus, SigningStatus } from '@prisma/client';
import { AppError, AppErrorCode } from '../../errors/app-error';
import type { EnvelopeIdOptions } from '../../utils/envelope';
@@ -105,8 +102,7 @@ export const createEnvelopeRecipients = async ({
signingOrder: recipient.signingOrder,
token: nanoid(),
sendStatus: recipient.role === RecipientRole.CC ? SendStatus.SENT : SendStatus.NOT_SENT,
signingStatus:
recipient.role === RecipientRole.CC ? SigningStatus.SIGNED : SigningStatus.NOT_SIGNED,
signingStatus: recipient.role === RecipientRole.CC ? SigningStatus.SIGNED : SigningStatus.NOT_SIGNED,
authOptions,
},
});
@@ -136,8 +132,6 @@ export const createEnvelopeRecipients = async ({
});
return {
recipients: createdRecipients.map((recipient) =>
mapRecipientToLegacyRecipient(recipient, envelope),
),
recipients: createdRecipients.map((recipient) => mapRecipientToLegacyRecipient(recipient, envelope)),
};
};
@@ -1,13 +1,11 @@
import { createElement } from 'react';
import { msg } from '@lingui/core/macro';
import { EnvelopeType, SendStatus } from '@prisma/client';
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 { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { prisma } from '@documenso/prisma';
import { msg } from '@lingui/core/macro';
import { EnvelopeType, SendStatus } from '@prisma/client';
import { createElement } from 'react';
import { getI18nInstance } from '../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
@@ -134,9 +132,7 @@ export const deleteEnvelopeRecipient = async ({
});
});
const isRecipientRemovedEmailEnabled = extractDerivedDocumentEmailSettings(
envelope.documentMeta,
).recipientRemoved;
const isRecipientRemovedEmailEnabled = extractDerivedDocumentEmailSettings(envelope.documentMeta).recipientRemoved;
// Send email to deleted recipient.
if (
@@ -1,6 +1,5 @@
import { DocumentSigningOrder, EnvelopeType, SigningStatus } from '@prisma/client';
import { prisma } from '@documenso/prisma';
import { DocumentSigningOrder, EnvelopeType, SigningStatus } from '@prisma/client';
export type GetIsRecipientTurnOptions = {
token: string;
@@ -1,6 +1,5 @@
import { EnvelopeType } from '@prisma/client';
import { prisma } from '@documenso/prisma';
import { EnvelopeType } from '@prisma/client';
import { mapDocumentIdToSecondaryId } from '../../utils/envelope';
@@ -1,6 +1,5 @@
import { EnvelopeType } from '@prisma/client';
import { prisma } from '@documenso/prisma';
import { EnvelopeType } from '@prisma/client';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { mapSecondaryIdToDocumentId, mapSecondaryIdToTemplateId } from '../../utils/envelope';
@@ -17,12 +16,7 @@ export type GetRecipientByIdOptions = {
* Get a recipient by ID. This will also return the recipient signing token so
* be careful when using this.
*/
export const getRecipientById = async ({
recipientId,
userId,
teamId,
type,
}: GetRecipientByIdOptions) => {
export const getRecipientById = async ({ recipientId, userId, teamId, type }: GetRecipientByIdOptions) => {
const recipient = await prisma.recipient.findFirst({
where: {
id: recipientId,
@@ -48,14 +42,8 @@ export const getRecipientById = async ({
}
const legacyId = {
documentId:
type === EnvelopeType.DOCUMENT
? mapSecondaryIdToDocumentId(recipient.envelope.secondaryId)
: null,
templateId:
type === EnvelopeType.TEMPLATE
? mapSecondaryIdToTemplateId(recipient.envelope.secondaryId)
: null,
documentId: type === EnvelopeType.DOCUMENT ? mapSecondaryIdToDocumentId(recipient.envelope.secondaryId) : null,
templateId: type === EnvelopeType.TEMPLATE ? mapSecondaryIdToTemplateId(recipient.envelope.secondaryId) : null,
};
// Backwards compatibility mapping.
@@ -1,7 +1,6 @@
import { EnvelopeType, Prisma } from '@prisma/client';
import { buildTeamWhereQuery } from '@documenso/lib/utils/teams';
import { prisma } from '@documenso/prisma';
import { EnvelopeType, Prisma } from '@prisma/client';
export type GetRecipientSuggestionsOptions = {
userId: number;
@@ -9,11 +8,7 @@ export type GetRecipientSuggestionsOptions = {
query: string;
};
export const getRecipientSuggestions = async ({
userId,
teamId,
query,
}: GetRecipientSuggestionsOptions) => {
export const getRecipientSuggestions = async ({ userId, teamId, query }: GetRecipientSuggestionsOptions) => {
const trimmedQuery = query.trim();
const nameEmailFilter = trimmedQuery
@@ -89,9 +84,7 @@ export const getRecipientSuggestions = async ({
take: 5,
});
const uniqueTeamMember = teamMembers.find(
(member) => !recipients.some((r) => r.email === member.user.email),
);
const uniqueTeamMember = teamMembers.find((member) => !recipients.some((r) => r.email === member.user.email));
if (uniqueTeamMember) {
const teamMemberSuggestion = {
@@ -1,6 +1,5 @@
import { FieldType } from '@prisma/client';
import { prisma } from '@documenso/prisma';
import { FieldType } from '@prisma/client';
import { AppError, AppErrorCode } from '../../errors/app-error';
@@ -1,6 +1,5 @@
import { EnvelopeType } from '@prisma/client';
import { prisma } from '@documenso/prisma';
import { EnvelopeType } from '@prisma/client';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
@@ -10,11 +9,7 @@ export interface GetRecipientsForDocumentOptions {
teamId: number;
}
export const getRecipientsForDocument = async ({
documentId,
userId,
teamId,
}: GetRecipientsForDocumentOptions) => {
export const getRecipientsForDocument = async ({ documentId, userId, teamId }: GetRecipientsForDocumentOptions) => {
const { envelopeWhereInput } = await getEnvelopeWhereInput({
id: {
type: 'documentId',
@@ -1,27 +1,18 @@
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 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 { createDocumentAuditLogData, diffRecipientChanges } from '@documenso/lib/utils/document-audit-logs';
import { createRecipientAuthOptions } from '@documenso/lib/utils/document-auth';
import { prisma } from '@documenso/prisma';
import { msg } from '@lingui/core/macro';
import type { Recipient } from '@prisma/client';
import { EnvelopeType, RecipientRole, SendStatus, SigningStatus } from '@prisma/client';
import { createElement } from 'react';
import { isDeepEqual } from 'remeda';
import { getI18nInstance } from '../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
@@ -120,17 +111,13 @@ export const setDocumentRecipients = async ({
const existingRecipients = envelope.recipients;
const removedRecipients = existingRecipients.filter(
(existingRecipient) =>
!normalizedRecipients.find((recipient) => recipient.id === existingRecipient.id),
(existingRecipient) => !normalizedRecipients.find((recipient) => recipient.id === existingRecipient.id),
);
const linkedRecipients = normalizedRecipients.map((recipient) => {
const existing = existingRecipients.find(
(existingRecipient) => existingRecipient.id === recipient.id,
);
const existing = existingRecipients.find((existingRecipient) => existingRecipient.id === recipient.id);
const canPersistedRecipientBeModified =
existing && canRecipientBeModified(existing, envelope.fields);
const canPersistedRecipientBeModified = existing && canRecipientBeModified(existing, envelope.fields);
if (
existing &&
@@ -180,8 +167,7 @@ export const setDocumentRecipients = async ({
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,
signingStatus: recipient.role === RecipientRole.CC ? SigningStatus.SIGNED : SigningStatus.NOT_SIGNED,
authOptions,
},
create: {
@@ -192,8 +178,7 @@ export const setDocumentRecipients = async ({
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,
signingStatus: recipient.role === RecipientRole.CC ? SigningStatus.SIGNED : SigningStatus.NOT_SIGNED,
authOptions,
},
});
@@ -220,9 +205,7 @@ export const setDocumentRecipients = async ({
recipientRole: upsertedRecipient.role,
};
const changes = recipient._persisted
? diffRecipientChanges(recipient._persisted, upsertedRecipient)
: [];
const changes = recipient._persisted ? diffRecipientChanges(recipient._persisted, upsertedRecipient) : [];
// Handle recipient updated audit log.
if (recipient._persisted && changes.length > 0) {
@@ -290,9 +273,7 @@ export const setDocumentRecipients = async ({
});
});
const isRecipientRemovedEmailEnabled = extractDerivedDocumentEmailSettings(
envelope.documentMeta,
).recipientRemoved;
const isRecipientRemovedEmailEnabled = extractDerivedDocumentEmailSettings(envelope.documentMeta).recipientRemoved;
// Send emails to deleted recipients who have emails.
await Promise.all(
@@ -338,12 +319,8 @@ export const setDocumentRecipients = async ({
// 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,
);
const isRemoved = removedRecipients.find((removedRecipient) => removedRecipient.id === recipient.id);
const isUpdated = persistedRecipients.find((persistedRecipient) => persistedRecipient.id === recipient.id);
return !isRemoved && !isUpdated;
});
@@ -1,17 +1,13 @@
import type { Recipient } from '@prisma/client';
import { EnvelopeType, RecipientRole } from '@prisma/client';
import {
DIRECT_TEMPLATE_RECIPIENT_EMAIL,
DIRECT_TEMPLATE_RECIPIENT_NAME,
} from '@documenso/lib/constants/direct-templates';
import { prisma } from '@documenso/prisma';
import type { Recipient } from '@prisma/client';
import { EnvelopeType, RecipientRole } from '@prisma/client';
import { AppError, AppErrorCode } from '../../errors/app-error';
import {
type TRecipientActionAuthTypes,
ZRecipientAuthOptionsSchema,
} from '../../types/document-auth';
import { type TRecipientActionAuthTypes, ZRecipientAuthOptionsSchema } from '../../types/document-auth';
import { nanoid } from '../../universal/id';
import { createRecipientAuthOptions } from '../../utils/document-auth';
import { type EnvelopeIdOptions, mapSecondaryIdToTemplateId } from '../../utils/envelope';
@@ -21,22 +17,10 @@ export type SetTemplateRecipientsOptions = {
userId: number;
teamId: number;
id: EnvelopeIdOptions;
recipients: {
id?: number;
email: string;
name: string;
role: RecipientRole;
signingOrder?: number | null;
actionAuth?: TRecipientActionAuthTypes[];
}[];
recipients: RecipientData[];
};
export const setTemplateRecipients = async ({
userId,
teamId,
id,
recipients,
}: SetTemplateRecipientsOptions) => {
export const setTemplateRecipients = async ({ userId, teamId, id, recipients }: SetTemplateRecipientsOptions) => {
const { envelopeWhereInput } = await getEnvelopeWhereInput({
id,
type: EnvelopeType.TEMPLATE,
@@ -95,8 +79,7 @@ export const setTemplateRecipients = async ({
const existingRecipients = envelope.recipients;
const removedRecipients = existingRecipients.filter(
(existingRecipient) =>
!normalizedRecipients.find((recipient) => recipient.id === existingRecipient.id),
(existingRecipient) => !normalizedRecipients.find((recipient) => recipient.id === existingRecipient.id),
);
if (envelope.directLink !== null) {
@@ -122,9 +105,7 @@ export const setTemplateRecipients = async ({
}
const linkedRecipients = normalizedRecipients.map((recipient) => {
const existing = existingRecipients.find(
(existingRecipient) => existingRecipient.id === recipient.id,
);
const existing = existingRecipients.find((existingRecipient) => existingRecipient.id === recipient.id);
return {
...recipient,
@@ -183,7 +164,10 @@ export const setTemplateRecipients = async ({
});
}
return upsertedRecipient;
return {
...upsertedRecipient,
clientId: recipient.clientId,
};
}),
);
});
@@ -199,13 +183,9 @@ export const setTemplateRecipients = async ({
}
// Filter out recipients that have been removed or have been updated.
const filteredRecipients: Recipient[] = existingRecipients.filter((recipient) => {
const isRemoved = removedRecipients.find(
(removedRecipient) => removedRecipient.id === recipient.id,
);
const isUpdated = persistedRecipients.find(
(persistedRecipient) => persistedRecipient.id === recipient.id,
);
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;
});
@@ -218,3 +198,17 @@ export const setTemplateRecipients = async ({
})),
};
};
type RecipientData = {
id?: number;
clientId?: string | null;
email: string;
name: string;
role: RecipientRole;
signingOrder?: number | null;
actionAuth?: TRecipientActionAuthTypes[];
};
type RecipientDataWithClientId = Recipient & {
clientId?: string | null;
};
@@ -1,22 +1,15 @@
import { EnvelopeType, RecipientRole, SendStatus, SigningStatus } from '@prisma/client';
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 TRecipientActionAuthTypes, ZRecipientAuthOptionsSchema } from '@documenso/lib/types/document-auth';
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import {
createDocumentAuditLogData,
diffRecipientChanges,
} from '@documenso/lib/utils/document-audit-logs';
import { createDocumentAuditLogData, diffRecipientChanges } from '@documenso/lib/utils/document-audit-logs';
import { createRecipientAuthOptions } from '@documenso/lib/utils/document-auth';
import { prisma } from '@documenso/prisma';
import { EnvelopeType, RecipientRole, SendStatus, SigningStatus } from '@prisma/client';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { extractLegacyIds } from '../../universal/id';
import { type EnvelopeIdOptions } from '../../utils/envelope';
import type { EnvelopeIdOptions } from '../../utils/envelope';
import { mapFieldToLegacyField } from '../../utils/fields';
import { canRecipientBeModified } from '../../utils/recipients';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
@@ -92,9 +85,7 @@ export const updateEnvelopeRecipients = async ({
}
const recipientsToUpdate = recipients.map((recipient) => {
const originalRecipient = envelope.recipients.find(
(existingRecipient) => existingRecipient.id === recipient.id,
);
const originalRecipient = envelope.recipients.find((existingRecipient) => existingRecipient.id === recipient.id);
if (!originalRecipient) {
throw new AppError(AppErrorCode.NOT_FOUND, {
@@ -142,12 +133,8 @@ export const updateEnvelopeRecipients = async ({
role: mergedRecipient.role,
signingOrder: mergedRecipient.signingOrder,
envelopeId: envelope.id,
sendStatus:
mergedRecipient.role === RecipientRole.CC ? SendStatus.SENT : SendStatus.NOT_SENT,
signingStatus:
mergedRecipient.role === RecipientRole.CC
? SigningStatus.SIGNED
: SigningStatus.NOT_SIGNED,
sendStatus: mergedRecipient.role === RecipientRole.CC ? SendStatus.SENT : SendStatus.NOT_SENT,
signingStatus: mergedRecipient.role === RecipientRole.CC ? SigningStatus.SIGNED : SigningStatus.NOT_SIGNED,
authOptions,
},
include: {
@@ -158,8 +145,7 @@ export const updateEnvelopeRecipients = async ({
// Clear all fields if the recipient role is changed to a type that cannot have fields.
if (
originalRecipient.role !== updatedRecipient.role &&
(updatedRecipient.role === RecipientRole.CC ||
updatedRecipient.role === RecipientRole.VIEWER)
(updatedRecipient.role === RecipientRole.CC || updatedRecipient.role === RecipientRole.VIEWER)
) {
await tx.field.deleteMany({
where: {
@@ -0,0 +1,106 @@
import { prisma } from '@documenso/prisma';
import { DocumentDistributionMethod, RecipientRole, SendStatus, SigningStatus } from '@prisma/client';
import { resolveNextReminderAt, ZEnvelopeReminderSettings } from '../../constants/envelope-reminder';
/**
* Compute and store `nextReminderAt` for a single recipient.
*
* Call this after:
* - Sending the signing email (sentAt is set)
* - Sending a reminder (lastReminderSentAt is updated)
*
* If `reminderSettings` is provided it's used directly, avoiding a query.
* Otherwise it's read from the envelope's documentMeta (already resolved
* from the org/team cascade at envelope creation time).
*/
export const updateRecipientNextReminder = async (options: {
recipientId: number;
envelopeId: string;
sentAt: Date;
lastReminderSentAt: Date | null;
reminderSettings?: ReturnType<typeof ZEnvelopeReminderSettings.parse> | null;
}) => {
const { recipientId, envelopeId, sentAt, lastReminderSentAt } = options;
let settings = options.reminderSettings;
if (settings === undefined) {
const envelope = await prisma.envelope.findFirst({
where: { id: envelopeId },
select: { documentMeta: { select: { reminderSettings: true } } },
});
settings = envelope?.documentMeta?.reminderSettings
? ZEnvelopeReminderSettings.parse(envelope.documentMeta.reminderSettings)
: null;
}
const nextReminderAt = resolveNextReminderAt({
config: settings,
sentAt,
lastReminderSentAt,
});
await prisma.recipient.update({
where: { id: recipientId },
data: { nextReminderAt },
});
};
/**
* Recompute `nextReminderAt` for all active (unsigned, sent) recipients
* of a given envelope. Call when document-level reminder settings change.
*/
export const recomputeNextReminderForEnvelope = async (envelopeId: string) => {
const envelope = await prisma.envelope.findFirst({
where: { id: envelopeId },
select: {
documentMeta: {
select: { reminderSettings: true, distributionMethod: true },
},
},
});
// No reminders for manually distributed documents.
const isEmailDistribution = envelope?.documentMeta?.distributionMethod !== DocumentDistributionMethod.NONE;
const settings =
isEmailDistribution && envelope?.documentMeta?.reminderSettings
? ZEnvelopeReminderSettings.parse(envelope.documentMeta.reminderSettings)
: null;
const now = new Date();
const recipients = await prisma.recipient.findMany({
where: {
envelopeId,
signingStatus: SigningStatus.NOT_SIGNED,
sendStatus: SendStatus.SENT,
sentAt: { not: null },
role: { not: RecipientRole.CC },
// Don't reschedule reminders for recipients whose deadline has passed.
OR: [{ expiresAt: null }, { expiresAt: { gt: now } }],
},
select: { id: true, sentAt: true, lastReminderSentAt: true },
});
await Promise.all(
recipients.map(async (recipient) => {
if (!recipient.sentAt) {
return;
}
const nextReminderAt = resolveNextReminderAt({
config: settings,
sentAt: recipient.sentAt,
lastReminderSentAt: recipient.lastReminderSentAt,
});
await prisma.recipient.update({
where: { id: recipient.id },
data: { nextReminderAt },
});
}),
);
};