mirror of
https://github.com/documenso/documenso.git
synced 2026-07-26 09:54:51 +10:00
Merge branch 'main' into feat/public-completed-document-access
This commit is contained in:
@@ -571,6 +571,22 @@ export const formatDocumentAuditLogAction = (
|
||||
you: msg`You deleted an envelope item with title ${data.envelopeItemTitle}`,
|
||||
user: msg`${user} deleted an envelope item with title ${data.envelopeItemTitle}`,
|
||||
}))
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.ENVELOPE_ITEM_UPDATED }, () => ({
|
||||
anonymous: msg({
|
||||
message: `Envelope item updated`,
|
||||
context: `Audit log format`,
|
||||
}),
|
||||
you: msg`You updated an envelope item`,
|
||||
user: msg`${user} updated an envelope item`,
|
||||
}))
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.ENVELOPE_ITEM_PDF_REPLACED }, ({ data }) => ({
|
||||
anonymous: msg({
|
||||
message: `Envelope item PDF replaced`,
|
||||
context: `Audit log format`,
|
||||
}),
|
||||
you: msg`You replaced the PDF for envelope item ${data.envelopeItemTitle}`,
|
||||
user: msg`${user} replaced the PDF for envelope item ${data.envelopeItemTitle}`,
|
||||
}))
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_EXPIRED }, ({ data }) => ({
|
||||
anonymous: msg({
|
||||
message: `Recipient signing window expired`,
|
||||
|
||||
@@ -66,6 +66,9 @@ export const extractDerivedDocumentMeta = (
|
||||
// Envelope expiration.
|
||||
envelopeExpirationPeriod:
|
||||
meta.envelopeExpirationPeriod ?? settings.envelopeExpirationPeriod ?? null,
|
||||
|
||||
// Reminder settings.
|
||||
reminderSettings: meta.reminderSettings ?? settings.reminderSettings ?? null,
|
||||
} satisfies Omit<DocumentMeta, 'id'>;
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import type { EnvelopeEditorConfig } from '../types/envelope-editor';
|
||||
import { DEFAULT_EMBEDDED_EDITOR_CONFIG } from '../types/envelope-editor';
|
||||
|
||||
export const PRESIGNED_ENVELOPE_ITEM_ID_PREFIX = 'PRESIGNED_';
|
||||
|
||||
export type DeepPartial<T> = T extends object ? { [K in keyof T]?: DeepPartial<T[K]> } : T;
|
||||
|
||||
/**
|
||||
* Takes parsed `features` from the embedding hash and an `embedded` config,
|
||||
* and produces a complete `EnvelopeEditorConfig` with sensible embedded-mode defaults.
|
||||
*
|
||||
* Any explicitly provided feature flag overrides the embedded default.
|
||||
*/
|
||||
export function buildEmbeddedEditorOptions(
|
||||
features: DeepPartial<EnvelopeEditorConfig>,
|
||||
embedded: EnvelopeEditorConfig['embedded'],
|
||||
): EnvelopeEditorConfig {
|
||||
return {
|
||||
embedded,
|
||||
...buildEmbeddedFeatures(features),
|
||||
};
|
||||
}
|
||||
|
||||
export const buildEmbeddedFeatures = (
|
||||
features: DeepPartial<EnvelopeEditorConfig>,
|
||||
): EnvelopeEditorConfig => {
|
||||
return {
|
||||
general: {
|
||||
allowConfigureEnvelopeTitle:
|
||||
features.general?.allowConfigureEnvelopeTitle ??
|
||||
DEFAULT_EMBEDDED_EDITOR_CONFIG.general.allowConfigureEnvelopeTitle,
|
||||
allowUploadAndRecipientStep:
|
||||
features.general?.allowUploadAndRecipientStep ??
|
||||
DEFAULT_EMBEDDED_EDITOR_CONFIG.general.allowUploadAndRecipientStep,
|
||||
allowAddFieldsStep:
|
||||
features.general?.allowAddFieldsStep ??
|
||||
DEFAULT_EMBEDDED_EDITOR_CONFIG.general.allowAddFieldsStep,
|
||||
allowPreviewStep:
|
||||
features.general?.allowPreviewStep ??
|
||||
DEFAULT_EMBEDDED_EDITOR_CONFIG.general.allowPreviewStep,
|
||||
minimizeLeftSidebar:
|
||||
features.general?.minimizeLeftSidebar ??
|
||||
DEFAULT_EMBEDDED_EDITOR_CONFIG.general.minimizeLeftSidebar,
|
||||
},
|
||||
|
||||
settings:
|
||||
features.settings !== null
|
||||
? {
|
||||
allowConfigureSignatureTypes:
|
||||
features.settings?.allowConfigureSignatureTypes ??
|
||||
DEFAULT_EMBEDDED_EDITOR_CONFIG.settings.allowConfigureSignatureTypes,
|
||||
allowConfigureLanguage:
|
||||
features.settings?.allowConfigureLanguage ??
|
||||
DEFAULT_EMBEDDED_EDITOR_CONFIG.settings.allowConfigureLanguage,
|
||||
allowConfigureDateFormat:
|
||||
features.settings?.allowConfigureDateFormat ??
|
||||
DEFAULT_EMBEDDED_EDITOR_CONFIG.settings.allowConfigureDateFormat,
|
||||
allowConfigureTimezone:
|
||||
features.settings?.allowConfigureTimezone ??
|
||||
DEFAULT_EMBEDDED_EDITOR_CONFIG.settings.allowConfigureTimezone,
|
||||
allowConfigureRedirectUrl:
|
||||
features.settings?.allowConfigureRedirectUrl ??
|
||||
DEFAULT_EMBEDDED_EDITOR_CONFIG.settings.allowConfigureRedirectUrl,
|
||||
allowConfigureDistribution:
|
||||
features.settings?.allowConfigureDistribution ??
|
||||
DEFAULT_EMBEDDED_EDITOR_CONFIG.settings.allowConfigureDistribution,
|
||||
allowConfigureExpirationPeriod:
|
||||
features.settings?.allowConfigureExpirationPeriod ??
|
||||
DEFAULT_EMBEDDED_EDITOR_CONFIG.settings.allowConfigureExpirationPeriod,
|
||||
allowConfigureReminders:
|
||||
features.settings?.allowConfigureReminders ??
|
||||
DEFAULT_EMBEDDED_EDITOR_CONFIG.settings.allowConfigureReminders,
|
||||
allowConfigureEmailSender:
|
||||
features.settings?.allowConfigureEmailSender ??
|
||||
DEFAULT_EMBEDDED_EDITOR_CONFIG.settings.allowConfigureEmailSender,
|
||||
allowConfigureEmailReplyTo:
|
||||
features.settings?.allowConfigureEmailReplyTo ??
|
||||
DEFAULT_EMBEDDED_EDITOR_CONFIG.settings.allowConfigureEmailReplyTo,
|
||||
}
|
||||
: null,
|
||||
|
||||
actions: {
|
||||
allowAttachments:
|
||||
features.actions?.allowAttachments ??
|
||||
DEFAULT_EMBEDDED_EDITOR_CONFIG.actions.allowAttachments,
|
||||
allowDistributing:
|
||||
features.actions?.allowDistributing ??
|
||||
DEFAULT_EMBEDDED_EDITOR_CONFIG.actions.allowDistributing,
|
||||
allowDirectLink:
|
||||
features.actions?.allowDirectLink ?? DEFAULT_EMBEDDED_EDITOR_CONFIG.actions.allowDirectLink,
|
||||
allowDuplication:
|
||||
features.actions?.allowDuplication ??
|
||||
DEFAULT_EMBEDDED_EDITOR_CONFIG.actions.allowDuplication,
|
||||
allowSaveAsTemplate:
|
||||
features.actions?.allowSaveAsTemplate ??
|
||||
DEFAULT_EMBEDDED_EDITOR_CONFIG.actions.allowSaveAsTemplate,
|
||||
allowDownloadPDF:
|
||||
features.actions?.allowDownloadPDF ??
|
||||
DEFAULT_EMBEDDED_EDITOR_CONFIG.actions.allowDownloadPDF,
|
||||
allowDeletion:
|
||||
features.actions?.allowDeletion ?? DEFAULT_EMBEDDED_EDITOR_CONFIG.actions.allowDeletion,
|
||||
},
|
||||
|
||||
envelopeItems:
|
||||
features.envelopeItems !== null
|
||||
? {
|
||||
allowConfigureTitle:
|
||||
features.envelopeItems?.allowConfigureTitle ??
|
||||
DEFAULT_EMBEDDED_EDITOR_CONFIG.envelopeItems.allowConfigureTitle,
|
||||
allowConfigureOrder:
|
||||
features.envelopeItems?.allowConfigureOrder ??
|
||||
DEFAULT_EMBEDDED_EDITOR_CONFIG.envelopeItems.allowConfigureOrder,
|
||||
allowUpload:
|
||||
features.envelopeItems?.allowUpload ??
|
||||
DEFAULT_EMBEDDED_EDITOR_CONFIG.envelopeItems.allowUpload,
|
||||
allowDelete:
|
||||
features.envelopeItems?.allowDelete ??
|
||||
DEFAULT_EMBEDDED_EDITOR_CONFIG.envelopeItems.allowDelete,
|
||||
allowReplace:
|
||||
features.envelopeItems?.allowReplace ??
|
||||
DEFAULT_EMBEDDED_EDITOR_CONFIG.envelopeItems.allowReplace,
|
||||
}
|
||||
: null,
|
||||
|
||||
recipients:
|
||||
features.recipients !== null
|
||||
? {
|
||||
allowAIDetection:
|
||||
features.recipients?.allowAIDetection ??
|
||||
DEFAULT_EMBEDDED_EDITOR_CONFIG.recipients.allowAIDetection,
|
||||
allowConfigureSigningOrder:
|
||||
features.recipients?.allowConfigureSigningOrder ??
|
||||
DEFAULT_EMBEDDED_EDITOR_CONFIG.recipients.allowConfigureSigningOrder,
|
||||
allowConfigureDictateNextSigner:
|
||||
features.recipients?.allowConfigureDictateNextSigner ??
|
||||
DEFAULT_EMBEDDED_EDITOR_CONFIG.recipients.allowConfigureDictateNextSigner,
|
||||
allowApproverRole:
|
||||
features.recipients?.allowApproverRole ??
|
||||
DEFAULT_EMBEDDED_EDITOR_CONFIG.recipients.allowApproverRole,
|
||||
allowViewerRole:
|
||||
features.recipients?.allowViewerRole ??
|
||||
DEFAULT_EMBEDDED_EDITOR_CONFIG.recipients.allowViewerRole,
|
||||
allowCCerRole:
|
||||
features.recipients?.allowCCerRole ??
|
||||
DEFAULT_EMBEDDED_EDITOR_CONFIG.recipients.allowCCerRole,
|
||||
allowAssistantRole:
|
||||
features.recipients?.allowAssistantRole ??
|
||||
DEFAULT_EMBEDDED_EDITOR_CONFIG.recipients.allowAssistantRole,
|
||||
}
|
||||
: null,
|
||||
|
||||
fields: {
|
||||
allowAIDetection:
|
||||
features.fields?.allowAIDetection ?? DEFAULT_EMBEDDED_EDITOR_CONFIG.fields.allowAIDetection,
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { EnvelopeItem } from '@prisma/client';
|
||||
|
||||
import type { DocumentDataVersion } from '@documenso/lib/types/document';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../constants/app';
|
||||
|
||||
export type EnvelopeItemPdfUrlOptions =
|
||||
@@ -34,3 +36,54 @@ export const getEnvelopeItemPdfUrl = (options: EnvelopeItemPdfUrlOptions) => {
|
||||
? `${NEXT_PUBLIC_WEBAPP_URL()}/api/files/token/${token}/envelopeItem/${id}${presignToken ? `?presignToken=${presignToken}` : ''}`
|
||||
: `${NEXT_PUBLIC_WEBAPP_URL()}/api/files/envelope/${envelopeId}/envelopeItem/${id}${presignToken ? `?token=${presignToken}` : ''}`;
|
||||
};
|
||||
|
||||
export type DocumentDataUrlOptions = {
|
||||
envelopeId: string;
|
||||
envelopeItemId: string;
|
||||
documentDataId: string;
|
||||
token: string | undefined;
|
||||
presignToken?: string | undefined;
|
||||
version: DocumentDataVersion;
|
||||
};
|
||||
|
||||
/**
|
||||
* The difference between this and `getEnvelopeItemPdfUrl` is that this will
|
||||
* hard cache since we add the `documentDataId` to the URL.
|
||||
*
|
||||
* Since `documentDataId` should change when the document is changed/signed, this is a
|
||||
* good way to cache an envelope item by.
|
||||
*/
|
||||
export const getDocumentDataUrl = (options: DocumentDataUrlOptions) => {
|
||||
const { envelopeId, envelopeItemId, documentDataId, token, presignToken, version } = options;
|
||||
|
||||
const partialUrl = `envelope/${envelopeId}/envelopeItem/${envelopeItemId}/dataId/${documentDataId}/${version}/item.pdf`;
|
||||
|
||||
// Recipient token endpoint.
|
||||
if (token) {
|
||||
return `${NEXT_PUBLIC_WEBAPP_URL()}/api/files/token/${token}/${partialUrl}`;
|
||||
}
|
||||
|
||||
// Endpoint authenticated by session or presigned token.
|
||||
const baseUrl = `${NEXT_PUBLIC_WEBAPP_URL()}/api/files/${partialUrl}`;
|
||||
|
||||
if (presignToken) {
|
||||
return `${baseUrl}?presignToken=${presignToken}`;
|
||||
}
|
||||
|
||||
return baseUrl;
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets a PDF url for the PDF viewer.
|
||||
*
|
||||
* Returns `null` if invalid.
|
||||
*/
|
||||
export const getDocumentDataUrlForPdfViewer = (options: DocumentDataUrlOptions): string | null => {
|
||||
const { envelopeId, envelopeItemId, documentDataId } = options;
|
||||
|
||||
if (!envelopeId || !envelopeItemId || !documentDataId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return getDocumentDataUrl(options);
|
||||
};
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
ZTextFieldMeta,
|
||||
} from '@documenso/lib/types/field-meta';
|
||||
import { toCheckboxCustomText, toRadioCustomText } from '@documenso/lib/utils/fields';
|
||||
import { zEmail } from '@documenso/lib/utils/zod';
|
||||
import type { TSignEnvelopeFieldValue } from '@documenso/trpc/server/envelope-router/sign-envelope-field.types';
|
||||
import { checkboxValidationSigns } from '@documenso/ui/primitives/document-flow/field-items-advanced-settings/constants';
|
||||
|
||||
@@ -37,7 +38,7 @@ export const extractFieldInsertionValues = ({
|
||||
}: ExtractFieldInsertionValuesOptions): { customText: string; inserted: boolean } => {
|
||||
return match(fieldValue)
|
||||
.with({ type: FieldType.EMAIL }, (fieldValue) => {
|
||||
const parsedEmailValue = z.string().email().nullable().safeParse(fieldValue.value);
|
||||
const parsedEmailValue = zEmail().nullable().safeParse(fieldValue.value);
|
||||
|
||||
if (!parsedEmailValue.success) {
|
||||
throw new AppError(AppErrorCode.INVALID_BODY, {
|
||||
|
||||
@@ -244,28 +244,57 @@ export const mapSecondaryIdToTemplateId = (secondaryId: string) => {
|
||||
return parseInt(parsed.data.split('_')[1]);
|
||||
};
|
||||
|
||||
export const canEnvelopeItemsBeModified = (
|
||||
envelope: Pick<Envelope, 'completedAt' | 'deletedAt' | 'type' | 'status'>,
|
||||
recipients: Recipient[],
|
||||
) => {
|
||||
if (envelope.completedAt || envelope.deletedAt || envelope.status !== DocumentStatus.DRAFT) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (envelope.type === EnvelopeType.TEMPLATE) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
recipients.some(
|
||||
(recipient) =>
|
||||
recipient.role !== RecipientRole.CC &&
|
||||
(recipient.signingStatus === SigningStatus.SIGNED ||
|
||||
recipient.sendStatus === SendStatus.SENT),
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
export type EnvelopeItemPermissions = {
|
||||
canTitleBeChanged: boolean;
|
||||
canFileBeChanged: boolean;
|
||||
canOrderBeChanged: boolean;
|
||||
};
|
||||
|
||||
export const getEnvelopeItemPermissions = (
|
||||
envelope: Pick<Envelope, 'completedAt' | 'deletedAt' | 'type' | 'status'>,
|
||||
recipients: Pick<Recipient, 'role' | 'signingStatus' | 'sendStatus'>[],
|
||||
): EnvelopeItemPermissions => {
|
||||
// Always reject completed/rejected/deleted envelopes.
|
||||
if (
|
||||
envelope.completedAt ||
|
||||
envelope.deletedAt ||
|
||||
envelope.status === DocumentStatus.REJECTED ||
|
||||
envelope.status === DocumentStatus.COMPLETED
|
||||
) {
|
||||
return {
|
||||
canTitleBeChanged: false,
|
||||
canFileBeChanged: false,
|
||||
canOrderBeChanged: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Templates can always be modified.
|
||||
if (envelope.type === EnvelopeType.TEMPLATE) {
|
||||
return {
|
||||
canTitleBeChanged: true,
|
||||
canFileBeChanged: true,
|
||||
canOrderBeChanged: true,
|
||||
};
|
||||
}
|
||||
|
||||
const hasActiveRecipients = recipients.some(
|
||||
(recipient) =>
|
||||
recipient.role !== RecipientRole.CC &&
|
||||
(recipient.signingStatus === SigningStatus.SIGNED ||
|
||||
recipient.signingStatus === SigningStatus.REJECTED ||
|
||||
recipient.sendStatus === SendStatus.SENT),
|
||||
);
|
||||
|
||||
return match(envelope.status)
|
||||
.with(DocumentStatus.DRAFT, () => ({
|
||||
canTitleBeChanged: true,
|
||||
canFileBeChanged: true,
|
||||
canOrderBeChanged: true,
|
||||
}))
|
||||
.with(DocumentStatus.PENDING, () => ({
|
||||
canTitleBeChanged: true,
|
||||
canFileBeChanged: false,
|
||||
canOrderBeChanged: !hasActiveRecipients, // Only allow order changes if no active recipients.
|
||||
}))
|
||||
.exhaustive();
|
||||
};
|
||||
|
||||
@@ -2,6 +2,8 @@ import type { I18n } from '@lingui/core';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { type Envelope, type Field, FieldType } from '@prisma/client';
|
||||
|
||||
import { PDF_VIEWER_CONTENT_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||
|
||||
import { extractLegacyIds } from '../universal/id';
|
||||
|
||||
/**
|
||||
@@ -23,25 +25,44 @@ export const sortFieldsByPosition = (fields: Field[]): Field[] => {
|
||||
*/
|
||||
export const validateFieldsInserted = (fields: Field[]): boolean => {
|
||||
const fieldCardElements = document.getElementsByClassName('field-card-container');
|
||||
const pdfContent = document.querySelector(PDF_VIEWER_CONTENT_SELECTOR);
|
||||
|
||||
// Attach validate attribute on all fields.
|
||||
const uninsertedFields = sortFieldsByPosition(fields.filter((field) => !field.inserted));
|
||||
|
||||
// All fields are inserted — clear the validation signal.
|
||||
if (uninsertedFields.length === 0) {
|
||||
pdfContent?.removeAttribute('data-validate-fields');
|
||||
return true;
|
||||
}
|
||||
|
||||
// Attach validate attribute on all fields currently in the DOM.
|
||||
Array.from(fieldCardElements).forEach((element) => {
|
||||
element.setAttribute('data-validate', 'true');
|
||||
});
|
||||
|
||||
const uninsertedFields = sortFieldsByPosition(fields.filter((field) => !field.inserted));
|
||||
// Also set a signal on the PDF viewer container so that field elements that
|
||||
// mount later (e.g. after the virtual list scrolls to a new page) can pick
|
||||
// up the validation state.
|
||||
pdfContent?.setAttribute('data-validate-fields', 'true');
|
||||
|
||||
const firstUninsertedField = uninsertedFields[0];
|
||||
|
||||
const firstUninsertedFieldElement =
|
||||
firstUninsertedField && document.getElementById(`field-${firstUninsertedField.id}`);
|
||||
if (firstUninsertedField) {
|
||||
// Try direct element scroll first (works if the field's page is currently rendered).
|
||||
const firstUninsertedFieldElement = document.getElementById(`field-${firstUninsertedField.id}`);
|
||||
|
||||
if (firstUninsertedFieldElement) {
|
||||
firstUninsertedFieldElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
return false;
|
||||
if (firstUninsertedFieldElement) {
|
||||
firstUninsertedFieldElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
} else {
|
||||
// Field not in DOM (page virtualized away) — signal the PDF viewer to
|
||||
// scroll to the correct page via the data attribute.
|
||||
if (pdfContent) {
|
||||
pdfContent.setAttribute('data-scroll-to-page', String(firstUninsertedField.page));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return uninsertedFields.length === 0;
|
||||
return false;
|
||||
};
|
||||
|
||||
export const validateFieldsUninserted = (): boolean => {
|
||||
|
||||
@@ -9,6 +9,7 @@ import type { ORGANISATION_MEMBER_ROLE_MAP } from '@documenso/lib/constants/orga
|
||||
|
||||
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '../constants/date-formats';
|
||||
import { DEFAULT_ENVELOPE_EXPIRATION_PERIOD } from '../constants/envelope-expiration';
|
||||
import { DEFAULT_ENVELOPE_REMINDER_SETTINGS } from '../constants/envelope-reminder';
|
||||
import {
|
||||
LOWEST_ORGANISATION_ROLE,
|
||||
ORGANISATION_MEMBER_ROLE_HIERARCHY,
|
||||
@@ -143,6 +144,8 @@ export const generateDefaultOrganisationSettings = (): Omit<
|
||||
|
||||
envelopeExpirationPeriod: DEFAULT_ENVELOPE_EXPIRATION_PERIOD,
|
||||
|
||||
reminderSettings: DEFAULT_ENVELOPE_REMINDER_SETTINGS,
|
||||
|
||||
aiFeaturesEnabled: false,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import type { Envelope } from '@prisma/client';
|
||||
import { type Field, type Recipient, RecipientRole, SigningStatus } from '@prisma/client';
|
||||
import { z } from 'zod';
|
||||
import { type Field, RecipientRole, SigningStatus } from '@prisma/client';
|
||||
|
||||
import { isSignatureFieldType } from '@documenso/prisma/guards/is-signature-field';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../constants/app';
|
||||
import { AppError, AppErrorCode } from '../errors/app-error';
|
||||
import type { TRecipientLite } from '../types/recipient';
|
||||
import { extractLegacyIds } from '../universal/id';
|
||||
import { zEmail } from './zod';
|
||||
|
||||
/**
|
||||
* Roles that require fields to be assigned before a document can be distributed.
|
||||
@@ -20,7 +21,7 @@ export const RECIPIENT_ROLES_THAT_REQUIRE_FIELDS = [RecipientRole.SIGNER] as con
|
||||
*
|
||||
* Currently only SIGNERs are validated - they must have at least one signature field.
|
||||
*/
|
||||
export const getRecipientsWithMissingFields = <T extends Pick<Recipient, 'id' | 'role'>>(
|
||||
export const getRecipientsWithMissingFields = <T extends Pick<TRecipientLite, 'id' | 'role'>>(
|
||||
recipients: T[],
|
||||
fields: Pick<Field, 'type' | 'recipientId'>[],
|
||||
): T[] => {
|
||||
@@ -42,7 +43,10 @@ export const formatSigningLink = (token: string) => `${NEXT_PUBLIC_WEBAPP_URL()}
|
||||
/**
|
||||
* Whether a recipient can be modified by the document owner.
|
||||
*/
|
||||
export const canRecipientBeModified = (recipient: Recipient, fields: Field[]) => {
|
||||
export const canRecipientBeModified = (
|
||||
recipient: TRecipientLite,
|
||||
fields: Pick<Field, 'recipientId' | 'inserted'>[],
|
||||
) => {
|
||||
if (!recipient) {
|
||||
return false;
|
||||
}
|
||||
@@ -72,7 +76,10 @@ export const canRecipientBeModified = (recipient: Recipient, fields: Field[]) =>
|
||||
* - They are not a Viewer or CCer
|
||||
* - They can be modified (canRecipientBeModified)
|
||||
*/
|
||||
export const canRecipientFieldsBeModified = (recipient: Recipient, fields: Field[]) => {
|
||||
export const canRecipientFieldsBeModified = (
|
||||
recipient: TRecipientLite,
|
||||
fields: Pick<Field, 'recipientId' | 'inserted'>[],
|
||||
) => {
|
||||
if (!canRecipientBeModified(recipient, fields)) {
|
||||
return false;
|
||||
}
|
||||
@@ -81,7 +88,7 @@ export const canRecipientFieldsBeModified = (recipient: Recipient, fields: Field
|
||||
};
|
||||
|
||||
export const mapRecipientToLegacyRecipient = (
|
||||
recipient: Recipient,
|
||||
recipient: TRecipientLite,
|
||||
envelope: Pick<Envelope, 'type' | 'secondaryId'>,
|
||||
) => {
|
||||
const legacyId = extractLegacyIds(envelope);
|
||||
@@ -92,8 +99,19 @@ export const mapRecipientToLegacyRecipient = (
|
||||
};
|
||||
};
|
||||
|
||||
export const isRecipientEmailValidForSending = (recipient: Pick<Recipient, 'email'>) => {
|
||||
return z.string().email().safeParse(recipient.email).success;
|
||||
|
||||
export const findRecipientByEmail = <T extends { email: string }>({
|
||||
recipients,
|
||||
userEmail,
|
||||
teamEmail,
|
||||
}: {
|
||||
recipients: T[];
|
||||
userEmail: string;
|
||||
teamEmail?: string | null;
|
||||
}) => recipients.find((r) => r.email === userEmail || (teamEmail && r.email === teamEmail));
|
||||
|
||||
export const isRecipientEmailValidForSending = (recipient: Pick<TRecipientLite, 'email'>) => {
|
||||
return zEmail().safeParse(recipient.email).success;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -209,6 +209,8 @@ export const generateDefaultTeamSettings = (): Omit<TeamGlobalSettings, 'id' | '
|
||||
|
||||
envelopeExpirationPeriod: null,
|
||||
|
||||
reminderSettings: null,
|
||||
|
||||
aiFeaturesEnabled: null,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -51,7 +51,7 @@ export const mapEnvelopeToTemplateLite = (envelope: Envelope): TTemplateLite =>
|
||||
|
||||
return {
|
||||
id: legacyTemplateId,
|
||||
envelopeId: envelope.secondaryId,
|
||||
envelopeId: envelope.id,
|
||||
type: envelope.templateType,
|
||||
visibility: envelope.visibility,
|
||||
externalId: envelope.externalId,
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Race a promise against a timeout. Returns `null` if the timeout
|
||||
* fires before the promise settles.
|
||||
*/
|
||||
export const withTimeout = async <T>(promise: Promise<T>, timeoutMs: number) =>
|
||||
await Promise.race<T | null>([
|
||||
promise,
|
||||
new Promise<null>((resolve) => {
|
||||
setTimeout(() => resolve(null), timeoutMs);
|
||||
}),
|
||||
]);
|
||||
|
||||
/**
|
||||
* Wrapper around `fetch` that aborts the request after `timeoutMs`.
|
||||
* Throws with a descriptive message on timeout.
|
||||
*/
|
||||
export const fetchWithTimeout = async (
|
||||
input: string | URL | Request,
|
||||
init: RequestInit & { timeoutMs: number },
|
||||
) => {
|
||||
const { timeoutMs, ...fetchInit } = init;
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
try {
|
||||
return await fetch(input, { ...fetchInit, signal: controller.signal });
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === 'AbortError') {
|
||||
throw new Error(`Request timed out after ${timeoutMs}ms`);
|
||||
}
|
||||
|
||||
throw err;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* RFC 5322 compliant email regex.
|
||||
*
|
||||
* This is more permissive than Zod's built-in `.email()` validator which rejects
|
||||
* valid international characters (e.g. "Søren@gmail.com").
|
||||
*
|
||||
* Compiled once at module level to avoid re-compilation on every validation call.
|
||||
*/
|
||||
const EMAIL_REGEX =
|
||||
/^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~\u{0080}-\u{FFFF}-]+@[a-zA-Z0-9\u{0080}-\u{FFFF}](?:[a-zA-Z0-9\u{0080}-\u{FFFF}-]{0,61}[a-zA-Z0-9\u{0080}-\u{FFFF}])?(?:\.[a-zA-Z0-9\u{0080}-\u{FFFF}](?:[a-zA-Z0-9\u{0080}-\u{FFFF}-]{0,61}[a-zA-Z0-9\u{0080}-\u{FFFF}])?)*$/u;
|
||||
|
||||
const DEFAULT_EMAIL_MESSAGE = 'Invalid email address';
|
||||
|
||||
/**
|
||||
* Creates a Zod email schema using an RFC 5322 compliant regex.
|
||||
*
|
||||
* Supports international characters in the local part and domain
|
||||
* (e.g. "Søren@gmail.com", "user@dömain.com").
|
||||
*
|
||||
* Returns a standard `ZodString` so all string methods are chainable:
|
||||
* `.min()`, `.max()`, `.trim()`, `.toLowerCase()`, `.optional()`, `.nullish()`, etc.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* zEmail()
|
||||
* zEmail().min(1).max(254)
|
||||
* zEmail().trim().toLowerCase()
|
||||
* zEmail('Email is invalid')
|
||||
* zEmail({ message: 'Email is invalid' })
|
||||
* ```
|
||||
*/
|
||||
export const zEmail = (options?: string | { message?: string }) => {
|
||||
const message =
|
||||
typeof options === 'string' ? options : (options?.message ?? DEFAULT_EMAIL_MESSAGE);
|
||||
|
||||
return z.string().regex(EMAIL_REGEX, { message });
|
||||
};
|
||||
Reference in New Issue
Block a user