diff --git a/apps/remix/app/components/dialogs/sign-field-number-dialog.tsx b/apps/remix/app/components/dialogs/sign-field-number-dialog.tsx index 184c3e563..c1bed70f3 100644 --- a/apps/remix/app/components/dialogs/sign-field-number-dialog.tsx +++ b/apps/remix/app/components/dialogs/sign-field-number-dialog.tsx @@ -1,5 +1,4 @@ import { zodResolver } from '@hookform/resolvers/zod'; -import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro'; import { createCallable } from 'react-call'; @@ -28,49 +27,71 @@ import { } from '@documenso/ui/primitives/form/form'; import { Input } from '@documenso/ui/primitives/input'; -const createNumberFieldSchema = (fieldMeta: TNumberFieldMeta) => { - let schema = z.coerce.number({ - invalid_type_error: msg`Please enter a valid number`.id, - }); - - const { numberFormat, minValue, maxValue } = fieldMeta; - - if (typeof minValue === 'number') { - schema = schema.min(minValue); - } - - if (typeof maxValue === 'number') { - schema = schema.max(maxValue); - } - - if (numberFormat) { - const foundRegex = numberFormatValues.find((item) => item.value === numberFormat)?.regex; - - if (!foundRegex) { - return schema; - } - - return schema.refine( - (value) => { - return foundRegex.test(value.toString()); - }, - { - message: msg`Number needs to be formatted as ${numberFormat}`.id, - }, - ); - } - - return schema; -}; - export type SignFieldNumberDialogProps = { fieldMeta: TNumberFieldMeta; }; -export const SignFieldNumberDialog = createCallable( +export const SignFieldNumberDialog = createCallable( ({ call, fieldMeta }) => { const { t } = useLingui(); + // Needs to be inside dialog for translation purposes. + const createNumberFieldSchema = (fieldMeta: TNumberFieldMeta) => { + const { numberFormat, minValue, maxValue } = fieldMeta; + + if (numberFormat) { + const foundRegex = numberFormatValues.find((item) => item.value === numberFormat)?.regex; + + if (foundRegex) { + return z.string().refine( + (value) => { + return foundRegex.test(value.toString()); + }, + { + message: t`Number needs to be formatted as ${numberFormat}`, + }, + ); + } + } + + // Not gong to work with min/max numbers + number format + // Since currently doesn't work in V1 going to ignore for now. + return z.string().superRefine((value, ctx) => { + const isValidNumber = /^[0-9,.]+$/.test(value.toString()); + + if (!isValidNumber) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: t`Please enter a valid number`, + }); + + return; + } + + if (typeof minValue === 'number' && parseFloat(value) < minValue) { + ctx.addIssue({ + code: z.ZodIssueCode.too_small, + minimum: minValue, + inclusive: true, + type: 'number', + }); + + return; + } + + if (typeof maxValue === 'number' && parseFloat(value) > maxValue) { + ctx.addIssue({ + code: z.ZodIssueCode.too_big, + maximum: maxValue, + inclusive: true, + type: 'number', + }); + + return; + } + }); + }; + const ZSignFieldNumberFormSchema = z.object({ number: createNumberFieldSchema(fieldMeta), }); diff --git a/packages/lib/advanced-fields-validation/validate-number.ts b/packages/lib/advanced-fields-validation/validate-number.ts index ce00ee35c..a2d89447b 100644 --- a/packages/lib/advanced-fields-validation/validate-number.ts +++ b/packages/lib/advanced-fields-validation/validate-number.ts @@ -11,7 +11,7 @@ export const validateNumberField = ( const { minValue, maxValue, readOnly, required, numberFormat, fontSize } = fieldMeta || {}; - if (numberFormat) { + if (numberFormat && value.length > 0) { const foundRegex = numberFormatValues.find((item) => item.value === numberFormat)?.regex; if (!foundRegex) { diff --git a/packages/lib/client-only/hooks/use-page-renderer.ts b/packages/lib/client-only/hooks/use-page-renderer.ts index 6aa6bd3a2..59831b13a 100644 --- a/packages/lib/client-only/hooks/use-page-renderer.ts +++ b/packages/lib/client-only/hooks/use-page-renderer.ts @@ -107,6 +107,10 @@ export function usePageRenderer(renderFunction: RenderFunction) { stage: stage.current, pageLayer: pageLayer.current, }); + + void document.fonts.ready.then(function () { + pageLayer.current?.batchDraw(); + }); }); return () => { diff --git a/packages/lib/server-only/document/send-document.ts b/packages/lib/server-only/document/send-document.ts index 3b897da7f..f5d97fcca 100644 --- a/packages/lib/server-only/document/send-document.ts +++ b/packages/lib/server-only/document/send-document.ts @@ -1,4 +1,4 @@ -import type { DocumentData, Envelope, EnvelopeItem } from '@prisma/client'; +import type { DocumentData, Envelope, EnvelopeItem, Field } from '@prisma/client'; import { DocumentSigningOrder, DocumentStatus, @@ -182,80 +182,10 @@ export const sendDocument = async ({ // Validate and autoinsert fields for V2 envelopes. if (envelope.internalVersion === 2) { for (const unknownField of envelope.fields) { - const parsedField = ZFieldAndMetaSchema.safeParse(unknownField); + const fieldToAutoInsert = extractFieldAutoInsertValues(unknownField); - if (parsedField.error) { - throw new AppError(AppErrorCode.INVALID_REQUEST, { - message: 'One or more fields have invalid metadata. Error: ' + parsedField.error.message, - }); - } - - const field = parsedField.data; - const fieldId = unknownField.id; - - if (field.type === FieldType.RADIO) { - const { values = [] } = ZRadioFieldMeta.parse(field.fieldMeta); - - const checkedItemIndex = values.findIndex((value) => value.checked); - - if (checkedItemIndex !== -1) { - fieldsToAutoInsert.push({ - fieldId, - customText: toRadioCustomText(checkedItemIndex), - }); - } - } - - if (field.type === FieldType.DROPDOWN) { - const { defaultValue, values = [] } = ZDropdownFieldMeta.parse(field.fieldMeta); - - if (defaultValue && values.some((value) => value.value === defaultValue)) { - fieldsToAutoInsert.push({ - fieldId, - customText: defaultValue, - }); - } - } - - if (field.type === FieldType.CHECKBOX) { - const { - values = [], - validationRule, - validationLength, - } = ZCheckboxFieldMeta.parse(field.fieldMeta); - - const checkedIndices: number[] = []; - - values.forEach((value, i) => { - if (value.checked) { - checkedIndices.push(i); - } - }); - - let isValid = true; - - if (validationRule && validationLength) { - const validation = checkboxValidationSigns.find((sign) => sign.label === validationRule); - - if (!validation) { - throw new AppError(AppErrorCode.INVALID_REQUEST, { - message: 'Invalid checkbox validation rule', - }); - } - - isValid = validateCheckboxLength( - checkedIndices.length, - validation.value, - validationLength, - ); - } - - if (isValid && checkedIndices.length > 0) { - fieldsToAutoInsert.push({ - fieldId, - customText: toCheckboxCustomText(checkedIndices), - }); - } + if (fieldToAutoInsert) { + fieldsToAutoInsert.push(fieldToAutoInsert); } } } @@ -387,3 +317,86 @@ const injectFormValuesIntoDocument = async ( }, }); }; + +/** + * Extracts the auto insertion values for a given field. + * + * If field is not auto insertable, returns `null`. + */ +export const extractFieldAutoInsertValues = ( + unknownField: Field, +): { fieldId: number; customText: string } | null => { + const parsedField = ZFieldAndMetaSchema.safeParse(unknownField); + + if (parsedField.error) { + throw new AppError(AppErrorCode.INVALID_REQUEST, { + message: 'One or more fields have invalid metadata. Error: ' + parsedField.error.message, + }); + } + + const field = parsedField.data; + const fieldId = unknownField.id; + + if (field.type === FieldType.RADIO) { + const { values = [] } = ZRadioFieldMeta.parse(field.fieldMeta); + + const checkedItemIndex = values.findIndex((value) => value.checked); + + if (checkedItemIndex !== -1) { + return { + fieldId, + customText: toRadioCustomText(checkedItemIndex), + }; + } + } + + if (field.type === FieldType.DROPDOWN) { + const { defaultValue, values = [] } = ZDropdownFieldMeta.parse(field.fieldMeta); + + if (defaultValue && values.some((value) => value.value === defaultValue)) { + return { + fieldId, + customText: defaultValue, + }; + } + } + + if (field.type === FieldType.CHECKBOX) { + const { + values = [], + validationRule, + validationLength, + } = ZCheckboxFieldMeta.parse(field.fieldMeta); + + const checkedIndices: number[] = []; + + values.forEach((value, i) => { + if (value.checked) { + checkedIndices.push(i); + } + }); + + let isValid = true; + + if (validationRule && validationLength) { + const validation = checkboxValidationSigns.find((sign) => sign.label === validationRule); + + if (!validation) { + throw new AppError(AppErrorCode.INVALID_REQUEST, { + message: 'Invalid checkbox validation rule', + }); + } + + isValid = validateCheckboxLength(checkedIndices.length, validation.value, validationLength); + } + + if (isValid && checkedIndices.length > 0) { + return { + fieldId, + customText: toCheckboxCustomText(checkedIndices), + }; + } + } + + return null; +}; diff --git a/packages/lib/server-only/envelope/get-envelope-for-direct-template-signing.ts b/packages/lib/server-only/envelope/get-envelope-for-direct-template-signing.ts index 3e6fc2af5..7957ef40f 100644 --- a/packages/lib/server-only/envelope/get-envelope-for-direct-template-signing.ts +++ b/packages/lib/server-only/envelope/get-envelope-for-direct-template-signing.ts @@ -6,6 +6,7 @@ import { prisma } from '@documenso/prisma'; import { AppError, AppErrorCode } from '../../errors/app-error'; import { DocumentAccessAuth, type TDocumentAuthMethods } from '../../types/document-auth'; import { extractDocumentAuthMethods } from '../../utils/document-auth'; +import { extractFieldAutoInsertValues } from '../document/send-document'; import { getTeamSettings } from '../team/get-team-settings'; import type { EnvelopeForSigningResponse } from './get-envelope-for-recipient-signing'; import { ZEnvelopeForSigningResponse } from './get-envelope-for-recipient-signing'; @@ -144,6 +145,19 @@ export const getEnvelopeForDirectTemplateSigning = async ({ recipient: { ...recipient, directToken: envelope.directLink?.token || '', + fields: recipient.fields.map((field) => { + const autoInsertValue = extractFieldAutoInsertValues(field); + + if (!autoInsertValue) { + return field; + } + + return { + ...field, + inserted: true, + customText: autoInsertValue.customText, + }; + }), }, recipientSignature: null, isRecipientsTurn: true, diff --git a/packages/lib/server-only/field/set-fields-for-template.ts b/packages/lib/server-only/field/set-fields-for-template.ts index 2c36c164e..9d0d81f52 100644 --- a/packages/lib/server-only/field/set-fields-for-template.ts +++ b/packages/lib/server-only/field/set-fields-for-template.ts @@ -129,7 +129,7 @@ export const setFieldsForTemplate = async ({ if (field.type === FieldType.NUMBER && field.fieldMeta) { const numberFieldParsedMeta = ZNumberFieldMeta.parse(field.fieldMeta); const errors = validateNumberField( - String(numberFieldParsedMeta.value), + String(numberFieldParsedMeta.value || ''), numberFieldParsedMeta, ); if (errors.length > 0) { diff --git a/packages/lib/server-only/template/create-document-from-direct-template.ts b/packages/lib/server-only/template/create-document-from-direct-template.ts index 18f6133a9..cf852e030 100644 --- a/packages/lib/server-only/template/create-document-from-direct-template.ts +++ b/packages/lib/server-only/template/create-document-from-direct-template.ts @@ -215,6 +215,12 @@ export const createDocumentFromDirectTemplate = async ({ const fieldsToProcess = directTemplateRecipient.fields.filter((templateField) => { const signedFieldValue = signedFieldValues.find((value) => value.fieldId === templateField.id); + // Custom logic for V2 to include all fields, since v1 excludes read only + // and prefilled fields. + if (directTemplateEnvelope.internalVersion === 2) { + return true; + } + // Include if it's required or has a signed value return isRequiredField(templateField) || signedFieldValue !== undefined; }); @@ -468,19 +474,28 @@ export const createDocumentFromDirectTemplate = async ({ signingOrder: directTemplateRecipient.signingOrder, fields: { createMany: { - data: directTemplateNonSignatureFields.map(({ templateField, customText }) => ({ - envelopeId: createdEnvelope.id, - envelopeItemId: oldEnvelopeItemToNewEnvelopeItemIdMap[templateField.envelopeItemId], - type: templateField.type, - page: templateField.page, - positionX: templateField.positionX, - positionY: templateField.positionY, - width: templateField.width, - height: templateField.height, - customText: customText ?? '', - inserted: true, - fieldMeta: templateField.fieldMeta || Prisma.JsonNull, - })), + data: directTemplateNonSignatureFields.map(({ templateField, customText }) => { + let inserted = true; + + // Custom logic for V2 to only insert if values exist. + if (directTemplateEnvelope.internalVersion === 2) { + inserted = customText !== ''; + } + + return { + envelopeId: createdEnvelope.id, + envelopeItemId: oldEnvelopeItemToNewEnvelopeItemIdMap[templateField.envelopeItemId], + type: templateField.type, + page: templateField.page, + positionX: templateField.positionX, + positionY: templateField.positionY, + width: templateField.width, + height: templateField.height, + customText: customText ?? '', + inserted, + fieldMeta: templateField.fieldMeta || Prisma.JsonNull, + }; + }), }, }, }, diff --git a/packages/lib/utils/envelope-signing.ts b/packages/lib/utils/envelope-signing.ts index 10bc01593..12484f3d5 100644 --- a/packages/lib/utils/envelope-signing.ts +++ b/packages/lib/utils/envelope-signing.ts @@ -102,7 +102,7 @@ export const extractFieldInsertionValues = ({ } const numberFieldParsedMeta = ZNumberFieldMeta.parse(field.fieldMeta); - const errors = validateNumberField(fieldValue.value.toString(), numberFieldParsedMeta, true); + const errors = validateNumberField(fieldValue.value, numberFieldParsedMeta, true); if (errors.length > 0) { throw new AppError(AppErrorCode.INVALID_BODY, { @@ -111,7 +111,7 @@ export const extractFieldInsertionValues = ({ } return { - customText: fieldValue.value.toString(), + customText: fieldValue.value, inserted: true, }; }) diff --git a/packages/prisma/seed/initial-seed.ts b/packages/prisma/seed/initial-seed.ts index 102af901a..fe77dc870 100644 --- a/packages/prisma/seed/initial-seed.ts +++ b/packages/prisma/seed/initial-seed.ts @@ -4,10 +4,17 @@ import path from 'node:path'; import { ALIGNMENT_TEST_FIELDS } from '@documenso/app-tests/constants/field-alignment-pdf'; import { FIELD_META_TEST_FIELDS } from '@documenso/app-tests/constants/field-meta-pdf'; import { isBase64Image } from '@documenso/lib/constants/signatures'; -import { incrementDocumentId } from '@documenso/lib/server-only/envelope/increment-id'; +import { + incrementDocumentId, + incrementTemplateId, +} from '@documenso/lib/server-only/envelope/increment-id'; import { nanoid, prefixedId } from '@documenso/lib/universal/id'; import { prisma } from '..'; +import { + DIRECT_TEMPLATE_RECIPIENT_EMAIL, + DIRECT_TEMPLATE_RECIPIENT_NAME, +} from '../../lib/constants/direct-templates'; import { DocumentDataType, DocumentSource, @@ -176,6 +183,26 @@ export const seedDatabase = async () => { userId: adminUser.user.id, teamId: adminUser.team.id, }), + seedAlignmentTestDocument({ + userId: exampleUser.user.id, + teamId: exampleUser.team.id, + recipientName: exampleUser.user.name || '', + recipientEmail: exampleUser.user.email, + insertFields: false, + status: DocumentStatus.DRAFT, + type: EnvelopeType.TEMPLATE, + }), + seedAlignmentTestDocument({ + userId: exampleUser.user.id, + teamId: exampleUser.team.id, + recipientName: exampleUser.user.name || '', + recipientEmail: exampleUser.user.email, + insertFields: false, + status: DocumentStatus.DRAFT, + type: EnvelopeType.TEMPLATE, + isDirectTemplate: true, + directTemplateToken: 'example', + }), seedAlignmentTestDocument({ userId: exampleUser.user.id, teamId: exampleUser.team.id, @@ -192,6 +219,26 @@ export const seedDatabase = async () => { insertFields: true, status: DocumentStatus.PENDING, }), + seedAlignmentTestDocument({ + userId: adminUser.user.id, + teamId: adminUser.team.id, + recipientName: adminUser.user.name || '', + recipientEmail: adminUser.user.email, + insertFields: false, + status: DocumentStatus.DRAFT, + type: EnvelopeType.TEMPLATE, + }), + seedAlignmentTestDocument({ + userId: adminUser.user.id, + teamId: adminUser.team.id, + recipientName: adminUser.user.name || '', + recipientEmail: adminUser.user.email, + insertFields: false, + status: DocumentStatus.DRAFT, + type: EnvelopeType.TEMPLATE, + isDirectTemplate: true, + directTemplateToken: 'admin', + }), seedAlignmentTestDocument({ userId: adminUser.user.id, teamId: adminUser.team.id, @@ -214,17 +261,25 @@ export const seedDatabase = async () => { export const seedAlignmentTestDocument = async ({ userId, teamId, + title = 'Envelope Full Field Test', recipientName, recipientEmail, insertFields, status, + type = EnvelopeType.DOCUMENT, + isDirectTemplate = false, + directTemplateToken, }: { userId: number; teamId: number; + title?: string; recipientName: string; recipientEmail: string; insertFields: boolean; status: DocumentStatus; + type?: EnvelopeType; + isDirectTemplate?: boolean; + directTemplateToken?: string; }) => { const alignmentPdf = fs .readFileSync(path.join(__dirname, '../../../assets/field-font-alignment.pdf')) @@ -237,7 +292,10 @@ export const seedAlignmentTestDocument = async ({ const alignmentDocumentData = await createDocumentData({ documentData: alignmentPdf }); const fieldMetaDocumentData = await createDocumentData({ documentData: fieldMetaPdf }); - const documentId = await incrementDocumentId(); + const secondaryId = + type === EnvelopeType.DOCUMENT + ? await incrementDocumentId().then((v) => v.formattedDocumentId) + : await incrementTemplateId().then((v) => v.formattedTemplateId); const documentMeta = await prisma.documentMeta.create({ data: {}, @@ -246,12 +304,12 @@ export const seedAlignmentTestDocument = async ({ const createdEnvelope = await prisma.envelope.create({ data: { id: prefixedId('envelope'), - secondaryId: documentId.formattedDocumentId, + secondaryId, internalVersion: 2, - type: EnvelopeType.DOCUMENT, + type, documentMetaId: documentMeta.id, source: DocumentSource.DOCUMENT, - title: `Envelope Full Field Test`, + title, status, envelopeItems: { createMany: { @@ -275,8 +333,8 @@ export const seedAlignmentTestDocument = async ({ teamId, recipients: { create: { - name: recipientName, - email: recipientEmail, + name: isDirectTemplate ? DIRECT_TEMPLATE_RECIPIENT_NAME : recipientName, + email: isDirectTemplate ? DIRECT_TEMPLATE_RECIPIENT_EMAIL : recipientEmail, token: nanoid(), sendStatus: status === 'DRAFT' ? SendStatus.NOT_SENT : SendStatus.SENT, signingStatus: status === 'COMPLETED' ? SigningStatus.SIGNED : SigningStatus.NOT_SIGNED, @@ -292,6 +350,25 @@ export const seedAlignmentTestDocument = async ({ const { id, recipients, envelopeItems } = createdEnvelope; + if (isDirectTemplate) { + const directTemplateRecpient = recipients.find( + (recipient) => recipient.email === DIRECT_TEMPLATE_RECIPIENT_EMAIL, + ); + + if (!directTemplateRecpient) { + throw new Error('Need to create a direct template recipient'); + } + + await prisma.templateDirectLink.create({ + data: { + envelopeId: id, + enabled: true, + token: directTemplateToken ?? Math.random().toString(), + directTemplateRecipientId: directTemplateRecpient.id, + }, + }); + } + const recipientId = recipients[0].id; const envelopeItemAlignmentItem = envelopeItems.find((item) => item.order === 1)?.id; const envelopeItemFieldMetaItem = envelopeItems.find((item) => item.order === 2)?.id; @@ -313,15 +390,16 @@ export const seedAlignmentTestDocument = async ({ insertFields && ((!field?.fieldMeta?.readOnly && Boolean(field.customText)) || field.type === 'SIGNATURE'), - signature: field.signature - ? { - create: { - recipientId, - signatureImageAsBase64: isBase64Image(field.signature) ? field.signature : null, - typedSignature: isBase64Image(field.signature) ? null : field.signature, - }, - } - : undefined, + signature: + field.signature && insertFields + ? { + create: { + recipientId, + signatureImageAsBase64: isBase64Image(field.signature) ? field.signature : null, + typedSignature: isBase64Image(field.signature) ? null : field.signature, + }, + } + : undefined, }, }); }), @@ -340,15 +418,16 @@ export const seedAlignmentTestDocument = async ({ insertFields && ((!field?.fieldMeta?.readOnly && Boolean(field.customText)) || field.type === 'SIGNATURE'), - signature: field.signature - ? { - create: { - recipientId, - signatureImageAsBase64: isBase64Image(field.signature) ? field.signature : null, - typedSignature: isBase64Image(field.signature) ? null : field.signature, - }, - } - : undefined, + signature: + field.signature && insertFields + ? { + create: { + recipientId, + signatureImageAsBase64: isBase64Image(field.signature) ? field.signature : null, + typedSignature: isBase64Image(field.signature) ? null : field.signature, + }, + } + : undefined, }, }); }), diff --git a/packages/trpc/server/envelope-router/sign-envelope-field.types.ts b/packages/trpc/server/envelope-router/sign-envelope-field.types.ts index b6d02a9e9..e02fbf2c5 100644 --- a/packages/trpc/server/envelope-router/sign-envelope-field.types.ts +++ b/packages/trpc/server/envelope-router/sign-envelope-field.types.ts @@ -16,7 +16,7 @@ export const ZSignEnvelopeFieldValue = z.discriminatedUnion('type', [ }), z.object({ type: z.literal(FieldType.NUMBER), - value: z.number().nullable(), + value: z.string().nullable(), }), z.object({ type: z.literal(FieldType.EMAIL),