feat: polish envelopes (#2090)

## Description

The rest of the owl
This commit is contained in:
David Nguyen
2025-10-24 16:22:06 +11:00
committed by GitHub
parent 88836404d1
commit 03eb6af69a
141 changed files with 5171 additions and 2402 deletions

View File

@ -69,11 +69,7 @@ test('[NEXT_RECIPIENT_DICTATION]: should allow updating next recipient when dict
// Verify next recipient info is shown
await expect(page.getByRole('dialog')).toBeVisible();
await expect(page.getByText('The next recipient to sign this document will be')).toBeVisible();
// Update next recipient
await page.locator('button').filter({ hasText: 'Update Recipient' }).click();
await page.waitForTimeout(1000);
await expect(page.getByText('Next Recipient Name')).toBeVisible();
// Use dialog context to ensure we're targeting the correct form fields
const dialog = page.getByRole('dialog');

View File

@ -458,7 +458,12 @@ test('[DOCUMENT_FLOW]: should be able to create, send with redirect url, sign a
expect(status).toBe(DocumentStatus.PENDING);
await page.getByRole('button', { name: 'Approve' }).click();
await expect(page.getByRole('dialog').getByText('Complete Approval').first()).toBeVisible();
await expect(
page
.getByRole('dialog')
.getByText('You are about to complete approving the following document')
.first(),
).toBeVisible();
await page.getByRole('button', { name: 'Approve' }).click();
await page.waitForURL('https://documenso.com');

View File

@ -268,17 +268,19 @@ test('[TEMPLATE]: should create a document from a template with custom document'
// Upload document.
const [fileChooser] = await Promise.all([
page.waitForEvent('filechooser'),
page.getByTestId('template-use-dialog-file-input').evaluate((e) => {
if (e instanceof HTMLInputElement) {
e.click();
}
}),
page
.locator(`#template-use-dialog-file-input-${template.envelopeItems[0].id}`)
.evaluate((e) => {
if (e instanceof HTMLInputElement) {
e.click();
}
}),
]);
await fileChooser.setFiles(EXAMPLE_PDF_PATH);
// Wait for upload to complete
await expect(page.getByText(path.basename(EXAMPLE_PDF_PATH))).toBeVisible();
await expect(page.getByText('Remove')).toBeVisible();
// Create document with custom document data
await page.getByRole('button', { name: 'Create as draft' }).click();
@ -367,17 +369,19 @@ test('[TEMPLATE]: should create a team document from a template with custom docu
// Upload document.
const [fileChooser] = await Promise.all([
page.waitForEvent('filechooser'),
page.getByTestId('template-use-dialog-file-input').evaluate((e) => {
if (e instanceof HTMLInputElement) {
e.click();
}
}),
page
.locator(`#template-use-dialog-file-input-${template.envelopeItems[0].id}`)
.evaluate((e) => {
if (e instanceof HTMLInputElement) {
e.click();
}
}),
]);
await fileChooser.setFiles(EXAMPLE_PDF_PATH);
// Wait for upload to complete
await expect(page.getByText(path.basename(EXAMPLE_PDF_PATH))).toBeVisible();
await expect(page.getByText('Remove')).toBeVisible();
// Create document with custom document data
await page.getByRole('button', { name: 'Create as draft' }).click();

View File

@ -83,7 +83,7 @@ test('[DIRECT_TEMPLATES]: toggle direct template link', async ({ page }) => {
await expect(page.getByText('Direct link signing has been').first()).toBeVisible();
// Check that the direct template link is no longer accessible.
await page.goto(formatDirectTemplatePath(template.directLink?.token || ''));
await page.goto(formatDirectTemplatePath(template.directLink?.token || '123'));
await expect(page.getByText('404 not found')).toBeVisible();
});

View File

@ -1,6 +1,6 @@
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { FREE_PLAN_LIMITS } from './constants';
import { DEFAULT_MINIMUM_ENVELOPE_ITEM_COUNT, FREE_PLAN_LIMITS } from './constants';
import type { TLimitsResponseSchema } from './schema';
import { ZLimitsResponseSchema } from './schema';
@ -29,6 +29,7 @@ export const getLimits = async ({ headers, teamId }: GetLimitsOptions) => {
return {
quota: FREE_PLAN_LIMITS,
remaining: FREE_PLAN_LIMITS,
maximumEnvelopeItemCount: DEFAULT_MINIMUM_ENVELOPE_ITEM_COUNT,
} satisfies TLimitsResponseSchema;
});
};

View File

@ -23,3 +23,8 @@ export const SELFHOSTED_PLAN_LIMITS: TLimitsSchema = {
recipients: Infinity,
directTemplates: Infinity,
};
/**
* Used as an initial value for the frontend before values are loaded from the server.
*/
export const DEFAULT_MINIMUM_ENVELOPE_ITEM_COUNT = 5;

View File

@ -3,7 +3,7 @@ import { createContext, useCallback, useContext, useEffect, useState } from 'rea
import { isDeepEqual } from 'remeda';
import { getLimits } from '../client';
import { FREE_PLAN_LIMITS } from '../constants';
import { DEFAULT_MINIMUM_ENVELOPE_ITEM_COUNT, FREE_PLAN_LIMITS } from '../constants';
import type { TLimitsResponseSchema } from '../schema';
export type LimitsContextValue = TLimitsResponseSchema & { refreshLimits: () => Promise<void> };
@ -30,6 +30,7 @@ export const LimitsProvider = ({
initialValue = {
quota: FREE_PLAN_LIMITS,
remaining: FREE_PLAN_LIMITS,
maximumEnvelopeItemCount: DEFAULT_MINIMUM_ENVELOPE_ITEM_COUNT,
},
teamId,
children,

View File

@ -1,5 +1,7 @@
import { z } from 'zod';
import { DEFAULT_MINIMUM_ENVELOPE_ITEM_COUNT } from './constants';
// Not proud of the below but it's a way to deal with Infinity when returning JSON.
export const ZLimitsSchema = z.object({
documents: z
@ -21,6 +23,7 @@ export type TLimitsSchema = z.infer<typeof ZLimitsSchema>;
export const ZLimitsResponseSchema = z.object({
quota: ZLimitsSchema,
remaining: ZLimitsSchema,
maximumEnvelopeItemCount: z.number().optional().default(DEFAULT_MINIMUM_ENVELOPE_ITEM_COUNT),
});
export type TLimitsResponseSchema = z.infer<typeof ZLimitsResponseSchema>;

View File

@ -23,13 +23,6 @@ export const getServerLimits = async ({
userId,
teamId,
}: GetServerLimitsOptions): Promise<TLimitsResponseSchema> => {
if (!IS_BILLING_ENABLED()) {
return {
quota: SELFHOSTED_PLAN_LIMITS,
remaining: SELFHOSTED_PLAN_LIMITS,
};
}
const organisation = await prisma.organisation.findFirst({
where: {
teams: {
@ -57,12 +50,22 @@ export const getServerLimits = async ({
const remaining = structuredClone(FREE_PLAN_LIMITS);
const subscription = organisation.subscription;
const maximumEnvelopeItemCount = organisation.organisationClaim.envelopeItemCount;
if (!IS_BILLING_ENABLED()) {
return {
quota: SELFHOSTED_PLAN_LIMITS,
remaining: SELFHOSTED_PLAN_LIMITS,
maximumEnvelopeItemCount,
};
}
// Bypass all limits even if plan expired for ENTERPRISE.
if (organisation.organisationClaimId === INTERNAL_CLAIM_ID.ENTERPRISE) {
return {
quota: PAID_PLAN_LIMITS,
remaining: PAID_PLAN_LIMITS,
maximumEnvelopeItemCount,
};
}
@ -71,6 +74,7 @@ export const getServerLimits = async ({
return {
quota: INACTIVE_PLAN_LIMITS,
remaining: INACTIVE_PLAN_LIMITS,
maximumEnvelopeItemCount,
};
}
@ -80,6 +84,7 @@ export const getServerLimits = async ({
return {
quota: PAID_PLAN_LIMITS,
remaining: PAID_PLAN_LIMITS,
maximumEnvelopeItemCount,
};
}
@ -117,5 +122,6 @@ export const getServerLimits = async ({
return {
quota,
remaining,
maximumEnvelopeItemCount,
};
};

View File

@ -1,3 +1,5 @@
import { match } from 'ts-pattern';
import { checkboxValidationSigns } from '@documenso/ui/primitives/document-flow/field-items-advanced-settings/constants';
import type { TCheckboxFieldMeta } from '../types/field-meta';
@ -75,3 +77,15 @@ export const validateCheckboxField = (
return errors;
};
export const validateCheckboxLength = (
numberOfSelectedOptions: number,
validationRule: '>=' | '=' | '<=',
validationLength: number,
) => {
return match(validationRule)
.with('>=', () => numberOfSelectedOptions >= validationLength)
.with('=', () => numberOfSelectedOptions === validationLength)
.with('<=', () => numberOfSelectedOptions <= validationLength)
.exhaustive();
};

View File

@ -29,7 +29,7 @@ export const validateNumberField = (
errors.push('Value is required');
}
if (!/^[0-9,.]+$/.test(value.trim())) {
if ((isSigningPage || value.length > 0) && !/^[0-9,.]+$/.test(value.trim())) {
errors.push(`Value is not a valid number`);
}

View File

@ -123,7 +123,6 @@ export const useEditorFields = ({
}
if (bypassCheck) {
console.log(3);
setSelectedFieldFormId(formId);
return;
}
@ -136,6 +135,7 @@ export const useEditorFields = ({
const field: TLocalField = {
...fieldData,
formId: nanoid(12),
...restrictFieldPosValues(fieldData),
};
append(field);
@ -165,7 +165,15 @@ export const useEditorFields = ({
const index = localFields.findIndex((field) => field.formId === formId);
if (index !== -1) {
update(index, { ...localFields[index], ...updates });
const updatedField = {
...localFields[index],
...updates,
};
update(index, {
...updatedField,
...restrictFieldPosValues(updatedField),
});
triggerFieldsUpdate();
}
},
@ -279,3 +287,14 @@ export const useEditorFields = ({
setSelectedRecipient,
};
};
const restrictFieldPosValues = (
field: Pick<TLocalField, 'positionX' | 'positionY' | 'width' | 'height'>,
) => {
return {
positionX: Math.max(0, Math.min(100, field.positionX)),
positionY: Math.max(0, Math.min(100, field.positionY)),
width: Math.max(0, Math.min(100, field.width)),
height: Math.max(0, Math.min(100, field.height)),
};
};

View File

@ -0,0 +1,126 @@
import { useEffect, useMemo, useRef } from 'react';
import Konva from 'konva';
import type { RenderParameters } from 'pdfjs-dist/types/src/display/api';
import { usePageContext } from 'react-pdf';
type RenderFunction = (props: { stage: Konva.Stage; pageLayer: Konva.Layer }) => void;
export function usePageRenderer(renderFunction: RenderFunction) {
const pageContext = usePageContext();
if (!pageContext) {
throw new Error('Unable to find Page context.');
}
const { page, rotate, scale } = pageContext;
if (!page) {
throw new Error('Attempted to render page canvas, but no page was specified.');
}
const canvasElement = useRef<HTMLCanvasElement>(null);
const konvaContainer = useRef<HTMLDivElement>(null);
const stage = useRef<Konva.Stage | null>(null);
const pageLayer = useRef<Konva.Layer | null>(null);
/**
* The raw viewport with no scaling. Basically the actual PDF size.
*/
const unscaledViewport = useMemo(
() => page.getViewport({ scale: 1, rotation: rotate }),
[page, rotate, scale],
);
/**
* The viewport scaled according to page width.
*/
const scaledViewport = useMemo(
() => page.getViewport({ scale, rotation: rotate }),
[page, rotate, scale],
);
/**
* Viewport with the device pixel ratio applied so we can render the PDF
* in a higher resolution.
*/
const renderViewport = useMemo(
() => page.getViewport({ scale: scale * window.devicePixelRatio, rotation: rotate }),
[page, rotate, scale],
);
/**
* Render the PDF and create the scaled Konva stage.
*/
useEffect(
function drawPageOnCanvas() {
if (!page) {
return;
}
const { current: canvas } = canvasElement;
const { current: kContainer } = konvaContainer;
if (!canvas || !kContainer) {
return;
}
canvas.width = renderViewport.width;
canvas.height = renderViewport.height;
canvas.style.width = `${Math.floor(scaledViewport.width)}px`;
canvas.style.height = `${Math.floor(scaledViewport.height)}px`;
const renderContext: RenderParameters = {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
canvasContext: canvas.getContext('2d', { alpha: false }) as CanvasRenderingContext2D,
viewport: renderViewport,
};
const cancellable = page.render(renderContext);
const runningTask = cancellable;
cancellable.promise.catch(() => {
// Intentionally empty
});
void cancellable.promise.then(() => {
stage.current = new Konva.Stage({
container: kContainer,
width: scaledViewport.width,
height: scaledViewport.height,
scale: {
x: scale,
y: scale,
},
});
// Create the main layer for interactive elements.
pageLayer.current = new Konva.Layer();
stage.current.add(pageLayer.current);
renderFunction({
stage: stage.current,
pageLayer: pageLayer.current,
});
});
return () => {
runningTask.cancel();
};
},
[page, scaledViewport],
);
return {
canvasElement,
konvaContainer,
stage,
pageLayer,
unscaledViewport,
scaledViewport,
pageContext,
};
}

View File

@ -5,15 +5,14 @@ import { EnvelopeType } from '@prisma/client';
import { trpc } from '@documenso/trpc/react';
import type { TSetEnvelopeRecipientsRequest } from '@documenso/trpc/server/envelope-router/set-envelope-recipients.types';
import type { RecipientColorStyles, TRecipientColor } from '@documenso/ui/lib/recipient-colors';
import {
AVAILABLE_RECIPIENT_COLORS,
getRecipientColorStyles,
} from '@documenso/ui/lib/recipient-colors';
import type { TUpdateEnvelopeRequest } from '@documenso/trpc/server/envelope-router/update-envelope.types';
import type { TRecipientColor } from '@documenso/ui/lib/recipient-colors';
import { AVAILABLE_RECIPIENT_COLORS } from '@documenso/ui/lib/recipient-colors';
import { useToast } from '@documenso/ui/primitives/use-toast';
import type { TDocumentEmailSettings } from '../../types/document-email';
import type { TEnvelope } from '../../types/envelope';
import { formatDocumentsPath, formatTemplatesPath } from '../../utils/teams';
import { useEditorFields } from '../hooks/use-editor-fields';
import type { TLocalField } from '../hooks/use-editor-fields';
import { useEnvelopeAutosave } from '../hooks/use-envelope-autosave';
@ -38,25 +37,35 @@ export const useDebounceFunction = <Args extends unknown[]>(
);
};
type UpdateEnvelopePayload = Pick<TUpdateEnvelopeRequest, 'data' | 'meta'>;
type EnvelopeEditorProviderValue = {
envelope: TEnvelope;
isDocument: boolean;
isTemplate: boolean;
setLocalEnvelope: (localEnvelope: Partial<TEnvelope>) => void;
updateEnvelope: (envelopeUpdates: Partial<TEnvelope>) => void;
updateEnvelope: (envelopeUpdates: UpdateEnvelopePayload) => void;
setRecipientsDebounced: (recipients: TSetEnvelopeRecipientsRequest['recipients']) => void;
setRecipientsAsync: (recipients: TSetEnvelopeRecipientsRequest['recipients']) => Promise<void>;
getFieldColor: (field: TLocalField) => RecipientColorStyles;
getRecipientColorKey: (recipientId: number) => TRecipientColor;
editorFields: ReturnType<typeof useEditorFields>;
isAutosaving: boolean;
flushAutosave: () => void;
flushAutosave: () => Promise<void>;
autosaveError: boolean;
relativePath: {
basePath: string;
envelopePath: string;
editorPath: string;
documentRootPath: string;
templateRootPath: string;
};
syncEnvelope: () => Promise<void>;
// refetchEnvelope: () => Promise<void>;
// updateEnvelope: (envelope: TEnvelope) => Promise<void>;
};
@ -86,12 +95,10 @@ export const EnvelopeEditorProvider = ({
const { toast } = useToast();
const [envelope, setEnvelope] = useState(initialEnvelope);
const [autosaveError, setAutosaveError] = useState<boolean>(false);
const envelopeUpdateMutationQuery = trpc.envelope.update.useMutation({
onSuccess: (response, input) => {
console.log(input.meta?.emailSettings);
setEnvelope({
...envelope,
...response,
@ -106,7 +113,9 @@ export const EnvelopeEditorProvider = ({
setAutosaveError(false);
},
onError: (error) => {
onError: (err) => {
console.error(err);
setAutosaveError(true);
toast({
@ -122,7 +131,9 @@ export const EnvelopeEditorProvider = ({
onSuccess: () => {
setAutosaveError(false);
},
onError: (error) => {
onError: (err) => {
console.error(err);
setAutosaveError(true);
toast({
@ -135,10 +146,17 @@ export const EnvelopeEditorProvider = ({
});
const envelopeRecipientSetMutationQuery = trpc.envelope.recipient.set.useMutation({
onSuccess: () => {
onSuccess: ({ recipients }) => {
setEnvelope((prev) => ({
...prev,
recipients,
}));
setAutosaveError(false);
},
onError: (error) => {
onError: (err) => {
console.error(err);
setAutosaveError(true);
toast({
@ -178,21 +196,28 @@ export const EnvelopeEditorProvider = ({
triggerSave: setEnvelopeDebounced,
flush: setEnvelopeAsync,
isPending: isEnvelopeMutationPending,
} = useEnvelopeAutosave(async (envelopeUpdates: Partial<TEnvelope>) => {
} = useEnvelopeAutosave(async (envelopeUpdates: UpdateEnvelopePayload) => {
await envelopeUpdateMutationQuery.mutateAsync({
envelopeId: envelope.id,
envelopeType: envelope.type,
data: {
...envelopeUpdates,
},
data: envelopeUpdates.data,
meta: envelopeUpdates.meta,
});
}, 1000);
/**
* Updates the local envelope and debounces the update to the server.
*/
const updateEnvelope = (envelopeUpdates: Partial<TEnvelope>) => {
setEnvelope((prev) => ({ ...prev, ...envelopeUpdates }));
const updateEnvelope = (envelopeUpdates: UpdateEnvelopePayload) => {
setEnvelope((prev) => ({
...prev,
...envelopeUpdates.data,
meta: {
...prev.documentMeta,
...envelopeUpdates.meta,
},
}));
setEnvelopeDebounced(envelopeUpdates);
};
@ -201,28 +226,17 @@ export const EnvelopeEditorProvider = ({
handleFieldsUpdate: (fields) => setFieldsDebounced(fields),
});
const getFieldColor = useCallback(
(field: TLocalField) => {
// Todo: Envelopes - Local recipients
const recipientIndex = envelope.recipients.findIndex(
(recipient) => recipient.id === field.recipientId,
);
return getRecipientColorStyles(Math.max(recipientIndex, 0));
},
[envelope.recipients], // Todo: Envelopes - Local recipients
);
const getRecipientColorKey = useCallback(
(recipientId: number) => {
// Todo: Envelopes - Local recipients
const recipientIndex = envelope.recipients.findIndex(
(recipient) => recipient.id === recipientId,
);
return AVAILABLE_RECIPIENT_COLORS[Math.max(recipientIndex, 0)];
return AVAILABLE_RECIPIENT_COLORS[
Math.max(recipientIndex, 0) % AVAILABLE_RECIPIENT_COLORS.length
];
},
[envelope.recipients], // Todo: Envelopes - Local recipients
[envelope.recipients],
);
const { refetch: reloadEnvelope, isLoading: isReloadingEnvelope } = trpc.envelope.get.useQuery(
@ -234,6 +248,21 @@ export const EnvelopeEditorProvider = ({
},
);
/**
* Fetch and sycn the envelope back into the editor.
*
* Overrides everything.
*/
const syncEnvelope = async () => {
await flushAutosave();
const fetchedEnvelopeData = await reloadEnvelope();
if (fetchedEnvelopeData.data) {
setEnvelope(fetchedEnvelopeData.data);
}
};
const setLocalEnvelope = (localEnvelope: Partial<TEnvelope>) => {
setEnvelope((prev) => ({ ...prev, ...localEnvelope }));
};
@ -256,10 +285,23 @@ export const EnvelopeEditorProvider = ({
isEnvelopeMutationPending,
]);
const flushAutosave = () => {
void setFieldsAsync();
void setRecipientsAsync();
void setEnvelopeAsync();
const relativePath = useMemo(() => {
const documentRootPath = formatDocumentsPath(envelope.team.url);
const templateRootPath = formatTemplatesPath(envelope.team.url);
const basePath = envelope.type === EnvelopeType.DOCUMENT ? documentRootPath : templateRootPath;
return {
basePath,
envelopePath: `${basePath}/${envelope.id}`,
editorPath: `${basePath}/${envelope.id}/edit`,
documentRootPath,
templateRootPath,
};
}, [envelope.type, envelope.id]);
const flushAutosave = async (): Promise<void> => {
await Promise.all([setFieldsAsync(), setRecipientsAsync(), setEnvelopeAsync()]);
};
return (
@ -269,7 +311,6 @@ export const EnvelopeEditorProvider = ({
isDocument: envelope.type === EnvelopeType.DOCUMENT,
isTemplate: envelope.type === EnvelopeType.TEMPLATE,
setLocalEnvelope,
getFieldColor,
getRecipientColorKey,
updateEnvelope,
setRecipientsDebounced,
@ -278,6 +319,8 @@ export const EnvelopeEditorProvider = ({
autosaveError,
flushAutosave,
isAutosaving,
relativePath,
syncEnvelope,
}}
>
{children}

View File

@ -2,6 +2,7 @@ import { NEXT_PUBLIC_WEBAPP_URL } from './app';
export const DEFAULT_STANDARD_FONT_SIZE = 12;
export const DEFAULT_HANDWRITING_FONT_SIZE = 50;
export const DEFAULT_SIGNATURE_TEXT_FONT_SIZE = 18;
export const MIN_STANDARD_FONT_SIZE = 8;
export const MIN_HANDWRITING_FONT_SIZE = 20;

View File

@ -18,6 +18,8 @@ const BACKPORT_SUBSCRIPTION_CLAIM_JOB_DEFINITION_SCHEMA = z.object({
embedSigning: z.literal(true).optional(),
embedSigningWhiteLabel: z.literal(true).optional(),
cfr21: z.literal(true).optional(),
// Todo: Envelopes - Do we need to check?
// authenticationPortal & emailDomains missing here.
}),
});

View File

@ -1,4 +1,12 @@
import { PDFDocument } from '@cantoo/pdf-lib';
import {
PDFDocument,
RotationTypes,
popGraphicsState,
pushGraphicsState,
radiansToDegrees,
rotateDegrees,
translate,
} from '@cantoo/pdf-lib';
import type { DocumentData, DocumentMeta, Envelope, EnvelopeItem, Field } from '@prisma/client';
import {
DocumentStatus,
@ -9,6 +17,8 @@ import {
} from '@prisma/client';
import { nanoid } from 'nanoid';
import path from 'node:path';
import { groupBy } from 'remeda';
import { match } from 'ts-pattern';
import { prisma } from '@documenso/prisma';
import { signPdf } from '@documenso/signing';
@ -21,6 +31,7 @@ import { getCertificatePdf } from '../../../server-only/htmltopdf/get-certificat
import { addRejectionStampToPdf } from '../../../server-only/pdf/add-rejection-stamp-to-pdf';
import { flattenAnnotations } from '../../../server-only/pdf/flatten-annotations';
import { flattenForm } from '../../../server-only/pdf/flatten-form';
import { getPageSize } from '../../../server-only/pdf/get-page-size';
import { insertFieldInPDFV1 } from '../../../server-only/pdf/insert-field-in-pdf-v1';
import { insertFieldInPDFV2 } from '../../../server-only/pdf/insert-field-in-pdf-v2';
import { legacy_insertFieldInPDF } from '../../../server-only/pdf/legacy-insert-field-in-pdf';
@ -178,9 +189,10 @@ export const run = async ({
settings,
});
// Todo: Envelopes - Is it okay to have dynamic IDs?
const newDocumentData = await Promise.all(
envelopeItems.map(async (envelopeItem) =>
io.runTask('decorate-and-sign-pdf', async () => {
io.runTask(`decorate-and-sign-envelope-item-${envelopeItem.id}`, async () => {
const envelopeItemFields = envelope.envelopeItems.find(
(item) => item.id === envelopeItem.id,
)?.field;
@ -353,14 +365,95 @@ const decorateAndSignPdf = async ({
});
}
for (const field of envelopeItemFields) {
if (field.inserted) {
if (envelope.internalVersion === 2) {
await insertFieldInPDFV2(pdfDoc, field);
} else if (envelope.useLegacyFieldInsertion) {
await legacy_insertFieldInPDF(pdfDoc, field);
} else {
await insertFieldInPDFV1(pdfDoc, field);
// Handle V1 and legacy insertions.
if (envelope.internalVersion === 1) {
for (const field of envelopeItemFields) {
if (field.inserted) {
if (envelope.useLegacyFieldInsertion) {
await legacy_insertFieldInPDF(pdfDoc, field);
} else {
await insertFieldInPDFV1(pdfDoc, field);
}
}
}
}
// Handle V2 envelope insertions.
if (envelope.internalVersion === 2) {
const fieldsGroupedByPage = groupBy(envelopeItemFields, (field) => field.page);
for (const [pageNumber, fields] of Object.entries(fieldsGroupedByPage)) {
const page = pdfDoc.getPage(Number(pageNumber) - 1);
const pageRotation = page.getRotation();
let { width: pageWidth, height: pageHeight } = getPageSize(page);
let pageRotationInDegrees = match(pageRotation.type)
.with(RotationTypes.Degrees, () => pageRotation.angle)
.with(RotationTypes.Radians, () => radiansToDegrees(pageRotation.angle))
.exhaustive();
// Round to the closest multiple of 90 degrees.
pageRotationInDegrees = Math.round(pageRotationInDegrees / 90) * 90;
// PDFs can have pages that are rotated, which are correctly rendered in the frontend.
// However when we load the PDF in the backend, the rotation is applied.
// To account for this, we swap the width and height for pages that are rotated by 90/270
// degrees. This is so we can calculate the virtual position the field was placed if it
// was correctly oriented in the frontend.
if (pageRotationInDegrees === 90 || pageRotationInDegrees === 270) {
[pageWidth, pageHeight] = [pageHeight, pageWidth];
}
// Rotate the page to the orientation that the react-pdf renders on the frontend.
// Note: These transformations are undone at the end of the function.
// If you change this if statement, update the if statement at the end as well
if (pageRotationInDegrees !== 0) {
let translateX = 0;
let translateY = 0;
switch (pageRotationInDegrees) {
case 90:
translateX = pageHeight;
translateY = 0;
break;
case 180:
translateX = pageWidth;
translateY = pageHeight;
break;
case 270:
translateX = 0;
translateY = pageWidth;
break;
case 0:
default:
translateX = 0;
translateY = 0;
}
page.pushOperators(pushGraphicsState());
page.pushOperators(translate(translateX, translateY), rotateDegrees(pageRotationInDegrees));
}
const renderedPdfOverlay = await insertFieldInPDFV2({
pageWidth,
pageHeight,
fields,
});
const [embeddedPage] = await pdfDoc.embedPdf(renderedPdfOverlay);
// Draw the SVG on the page
page.drawPage(embeddedPage, {
x: 0,
y: 0,
width: pageWidth,
height: pageHeight,
});
// Remove the transformations applied to the page if any were applied.
if (pageRotationInDegrees !== 0) {
page.pushOperators(popGraphicsState());
}
}
}

View File

@ -32,6 +32,7 @@ export const getDocumentWithDetailsById = async ({
return {
...envelope,
envelopeId: envelope.id,
internalVersion: envelope.internalVersion,
documentData: {
...firstDocumentData,
envelopeItemId: envelope.envelopeItems[0].id,

View File

@ -3,6 +3,7 @@ import {
DocumentSigningOrder,
DocumentStatus,
EnvelopeType,
FieldType,
RecipientRole,
SendStatus,
SigningStatus,
@ -13,9 +14,13 @@ import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-log
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { prisma } from '@documenso/prisma';
import { checkboxValidationSigns } from '@documenso/ui/primitives/document-flow/field-items-advanced-settings/constants';
import { validateCheckboxLength } from '../../advanced-fields-validation/validate-checkbox';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { jobs } from '../../jobs/client';
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
import { ZCheckboxFieldMeta, ZDropdownFieldMeta, ZRadioFieldMeta } from '../../types/field-meta';
import {
ZWebhookDocumentSchema,
mapEnvelopeToWebhookDocumentPayload,
@ -24,6 +29,7 @@ import { getFileServerSide } from '../../universal/upload/get-file.server';
import { putPdfFileServerSide } from '../../universal/upload/put-file.server';
import { isDocumentCompleted } from '../../utils/document';
import { type EnvelopeIdOptions, mapSecondaryIdToDocumentId } from '../../utils/envelope';
import { toCheckboxCustomText, toRadioCustomText } from '../../utils/fields';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
import { insertFormValuesInPdf } from '../pdf/insert-form-values-in-pdf';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
@ -56,6 +62,7 @@ export const sendDocument = async ({
recipients: {
orderBy: [{ signingOrder: { sort: 'asc', nulls: 'last' } }, { id: 'asc' }],
},
fields: true,
documentMeta: true,
envelopeItems: {
select: {
@ -165,6 +172,78 @@ export const sendDocument = async ({
});
}
const fieldsToAutoInsert: { fieldId: number; customText: string }[] = [];
// Auto insert radio and checkboxes that have default values.
if (envelope.internalVersion === 2) {
for (const field of envelope.fields) {
if (field.type === FieldType.RADIO) {
const { values = [] } = ZRadioFieldMeta.parse(field.fieldMeta);
const checkedItemIndex = values.findIndex((value) => value.checked);
if (checkedItemIndex !== -1) {
fieldsToAutoInsert.push({
fieldId: field.id,
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: field.id,
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) {
fieldsToAutoInsert.push({
fieldId: field.id,
customText: toCheckboxCustomText(checkedIndices),
});
}
}
}
}
const updatedEnvelope = await prisma.$transaction(async (tx) => {
if (envelope.status === DocumentStatus.DRAFT) {
await tx.documentAuditLog.create({
@ -177,6 +256,23 @@ export const sendDocument = async ({
});
}
// Todo: Envelopes - [AUDIT_LOGS]
if (envelope.internalVersion === 2) {
await Promise.all(
fieldsToAutoInsert.map(async (field) => {
await tx.field.update({
where: {
id: field.fieldId,
},
data: {
customText: field.customText,
inserted: true,
},
});
}),
);
}
return await tx.envelope.update({
where: {
id: envelope.id,

View File

@ -0,0 +1,144 @@
import { DocumentStatus, EnvelopeType } from '@prisma/client';
import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
import type { TDocumentAuthMethods } from '../../types/document-auth';
import { isRecipientAuthorized } from '../document/is-recipient-authorized';
import { getTeamSettings } from '../team/get-team-settings';
import type { EnvelopeForSigningResponse } from './get-envelope-for-recipient-signing';
import { ZEnvelopeForSigningResponse } from './get-envelope-for-recipient-signing';
export type GetRecipientEnvelopeByTokenOptions = {
token: string;
userId?: number;
accessAuth?: TDocumentAuthMethods;
};
/**
* Get all the values and details for a direct template envelope that a recipient requires.
*
* Do not overexpose any information that the recipient should not have.
*/
export const getEnvelopeForDirectTemplateSigning = async ({
token,
userId,
accessAuth,
}: GetRecipientEnvelopeByTokenOptions): Promise<EnvelopeForSigningResponse> => {
if (!token) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Missing token',
});
}
const envelope = await prisma.envelope.findFirst({
where: {
type: EnvelopeType.TEMPLATE,
status: DocumentStatus.DRAFT,
directLink: {
enabled: true,
token,
},
},
include: {
user: {
select: {
id: true,
email: true,
name: true,
},
},
documentMeta: true,
recipients: {
include: {
fields: {
include: {
signature: true,
},
},
},
orderBy: {
signingOrder: 'asc',
},
},
envelopeItems: {
include: {
documentData: true,
},
},
team: {
select: {
id: true,
name: true,
teamEmail: true,
teamGlobalSettings: {
select: {
includeSigningCertificate: true,
},
},
},
},
directLink: true,
},
});
const recipient = (envelope?.recipients || []).find(
(r) => r.id === envelope?.directLink?.directTemplateRecipientId,
);
if (!envelope || !recipient) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Envelope not found',
});
}
if (envelope.envelopeItems.length === 0) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Envelope has no items',
});
}
const documentAccessValid = await isRecipientAuthorized({
type: 'ACCESS',
documentAuthOptions: envelope.authOptions,
recipient,
userId,
authOptions: accessAuth,
});
if (!documentAccessValid) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'Invalid access values',
});
}
const settings = await getTeamSettings({ teamId: envelope.teamId });
const sender = settings.includeSenderDetails
? {
email: envelope.user.email,
name: envelope.user.name || '',
}
: {
email: envelope.team.teamEmail?.email || '',
name: envelope.team.name || '',
};
return ZEnvelopeForSigningResponse.parse({
envelope,
recipient: {
...recipient,
token: envelope.directLink?.token || '',
},
recipientSignature: null,
isRecipientsTurn: true,
isCompleted: false,
isRejected: false,
sender,
settings: {
includeSenderDetails: settings.includeSenderDetails,
brandingEnabled: settings.brandingEnabled,
brandingLogo: settings.brandingLogo,
},
} satisfies EnvelopeForSigningResponse);
};

View File

@ -23,7 +23,7 @@ export type GetRecipientEnvelopeByTokenOptions = {
accessAuth?: TDocumentAuthMethods;
};
const ZEnvelopeForSigningResponse = z.object({
export const ZEnvelopeForSigningResponse = z.object({
envelope: EnvelopeSchema.pick({
type: true,
status: true,
@ -31,6 +31,7 @@ const ZEnvelopeForSigningResponse = z.object({
secondaryId: true,
internalVersion: true,
completedAt: true,
updatedAt: true,
deletedAt: true,
title: true,
authOptions: true,

View File

@ -54,3 +54,54 @@ export const getEnvelopeRequiredAccessData = async ({ token }: { token: string }
recipientHasAccount: Boolean(recipientUserAccount),
} as const;
};
export const getEnvelopeDirectTemplateRequiredAccessData = async ({ token }: { token: string }) => {
const envelope = await prisma.envelope.findFirst({
where: {
type: EnvelopeType.TEMPLATE,
directLink: {
enabled: true,
token,
},
status: DocumentStatus.DRAFT,
},
include: {
recipients: {
where: {
token,
},
},
directLink: true,
},
});
if (!envelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Envelope not found',
});
}
const recipient = envelope.recipients.find(
(r) => r.id === envelope.directLink?.directTemplateRecipientId,
);
if (!recipient) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Recipient not found',
});
}
const recipientUserAccount = await prisma.user.findFirst({
where: {
email: recipient.email.toLowerCase(),
},
select: {
id: true,
},
});
return {
recipientEmail: recipient.email,
recipientHasAccount: Boolean(recipientUserAccount),
} as const;
};

View File

@ -156,9 +156,11 @@ export const setFieldsForDocument = async ({
if (field.type === FieldType.NUMBER && field.fieldMeta) {
const numberFieldParsedMeta = ZNumberFieldMeta.parse(field.fieldMeta);
const errors = validateNumberField(
String(numberFieldParsedMeta.value),
numberFieldParsedMeta,
false,
);
if (errors.length > 0) {

View File

@ -205,6 +205,7 @@ export const createOrganisationClaimUpsertData = (subscriptionClaim: InternalCla
flags: {
...subscriptionClaim.flags,
},
envelopeItemCount: subscriptionClaim.envelopeItemCount,
teamCount: subscriptionClaim.teamCount,
memberCount: subscriptionClaim.memberCount,
};

View File

@ -1,133 +1,64 @@
import type { PDFDocument } from '@cantoo/pdf-lib';
import { RotationTypes, radiansToDegrees } from '@cantoo/pdf-lib';
import fontkit from '@pdf-lib/fontkit';
import Konva from 'konva';
import 'konva/skia-backend';
import fs from 'node:fs';
import path from 'node:path';
import type { Canvas } from 'skia-canvas';
import { match } from 'ts-pattern';
import { FontLibrary } from 'skia-canvas';
import { isSignatureFieldType } from '@documenso/prisma/guards/is-signature-field';
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { renderField } from '../../universal/field-renderer/render-field';
import { getPageSize } from './get-page-size';
// const font = await pdf.embedFont(
// isSignatureField ? fontCaveat : fontNoto,
// isSignatureField ? { features: { calt: false } } : undefined,
// );
// const minFontSize = isSignatureField ? MIN_HANDWRITING_FONT_SIZE : MIN_STANDARD_FONT_SIZE;
// const maxFontSize = isSignatureField ? DEFAULT_HANDWRITING_FONT_SIZE : DEFAULT_STANDARD_FONT_SIZE;
type InsertFieldInPDFV2Options = {
pageWidth: number;
pageHeight: number;
fields: FieldWithSignature[];
};
export const insertFieldInPDFV2 = async (pdf: PDFDocument, field: FieldWithSignature) => {
const [fontCaveat, fontNoto] = await Promise.all([
fetch(`${NEXT_PUBLIC_WEBAPP_URL()}/fonts/caveat.ttf`).then(async (res) => res.arrayBuffer()),
fetch(`${NEXT_PUBLIC_WEBAPP_URL()}/fonts/noto-sans.ttf`).then(async (res) => res.arrayBuffer()),
export const insertFieldInPDFV2 = async ({
pageWidth,
pageHeight,
fields,
}: InsertFieldInPDFV2Options) => {
const fontPath = path.join(process.cwd(), 'public/fonts');
FontLibrary.use([
path.join(fontPath, 'caveat.ttf'),
path.join(fontPath, 'noto-sans.ttf'),
path.join(fontPath, 'noto-sans-japanese.ttf'),
path.join(fontPath, 'noto-sans-chinese.ttf'),
path.join(fontPath, 'noto-sans-korean.ttf'),
]);
const isSignatureField = isSignatureFieldType(field.type);
pdf.registerFontkit(fontkit);
const pages = pdf.getPages();
const page = pages.at(field.page - 1);
if (!page) {
throw new Error(`Page ${field.page} does not exist`);
}
const pageRotation = page.getRotation();
let pageRotationInDegrees = match(pageRotation.type)
.with(RotationTypes.Degrees, () => pageRotation.angle)
.with(RotationTypes.Radians, () => radiansToDegrees(pageRotation.angle))
.exhaustive();
// Round to the closest multiple of 90 degrees.
pageRotationInDegrees = Math.round(pageRotationInDegrees / 90) * 90;
const isPageRotatedToLandscape = pageRotationInDegrees === 90 || pageRotationInDegrees === 270;
// Todo: Evenloeps - getPageSize this had extra logic? Ask lucas
console.log({
cropBox: page.getCropBox(),
mediaBox: page.getMediaBox(),
mediaBox2: page.getSize(),
});
const { width: pageWidth, height: pageHeight } = getPageSize(page);
// PDFs can have pages that are rotated, which are correctly rendered in the frontend.
// However when we load the PDF in the backend, the rotation is applied.
//
// To account for this, we swap the width and height for pages that are rotated by 90/270
// degrees. This is so we can calculate the virtual position the field was placed if it
// was correctly oriented in the frontend.
//
// Then when we insert the fields, we apply a transformation to the position of the field
// so it is rotated correctly.
if (isPageRotatedToLandscape) {
// [pageWidth, pageHeight] = [pageHeight, pageWidth];
}
console.log({
pageWidth,
pageHeight,
fieldWidth: field.width,
fieldHeight: field.height,
});
const stage = new Konva.Stage({ width: pageWidth, height: pageHeight });
const layer = new Konva.Layer();
// Will render onto the layer.
renderField({
field: {
renderId: field.id.toString(),
...field,
width: Number(field.width),
height: Number(field.height),
positionX: Number(field.positionX),
positionY: Number(field.positionY),
},
pageLayer: layer,
pageWidth,
pageHeight,
mode: 'export',
});
const insertedFields = fields.filter((field) => field.inserted);
// Render the fields onto the layer.
for (const field of insertedFields) {
renderField({
scale: 1,
field: {
renderId: field.id.toString(),
...field,
width: Number(field.width),
height: Number(field.height),
positionX: Number(field.positionX),
positionY: Number(field.positionY),
},
translations: null,
pageLayer: layer,
pageWidth,
pageHeight,
mode: 'export',
});
}
stage.add(layer);
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const canvas = layer.canvas._canvas as unknown as Canvas;
const renderedField = await canvas.toBuffer('svg');
fs.writeFileSync(
`rendered-field-${field.envelopeId}--${field.id}.svg`,
renderedField.toString('utf-8'),
);
// Embed the SVG into the PDF
const svgElement = await pdf.embedSvg(renderedField.toString('utf-8'));
// Calculate position to cover the whole page
// pdf-lib coordinates: (0,0) is bottom-left, y increases upward
const svgWidth = pageWidth; // Use full page width
const svgHeight = pageHeight; // Use full page height
const x = 0; // Start from left edge
const y = pageHeight; // Start from bottom edge
// Draw the SVG on the page
page.drawSvg(svgElement, {
x: x,
y: y,
width: svgWidth,
height: svgHeight,
});
return pdf;
return await canvas.toBuffer('pdf');
};

View File

@ -162,12 +162,6 @@ export const createDocumentFromDirectTemplate = async ({
throw new AppError(AppErrorCode.INVALID_REQUEST, { message: 'Template no longer matches' });
}
if (user && user.email !== directRecipientEmail) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Email must match if you are logged in',
});
}
const { derivedRecipientAccessAuth, documentAuthOption: templateAuthOptions } =
extractDocumentAuthMethods({
documentAuth: directTemplateEnvelope.authOptions,
@ -340,7 +334,7 @@ export const createDocumentFromDirectTemplate = async ({
id: prefixedId('envelope'),
secondaryId: incrementedDocumentId.formattedDocumentId,
type: EnvelopeType.DOCUMENT,
internalVersion: 1,
internalVersion: directTemplateEnvelope.internalVersion,
qrToken: prefixedId('qr'),
source: DocumentSource.TEMPLATE_DIRECT_LINK,
templateId: directTemplateEnvelopeLegacyId,

View File

@ -209,6 +209,7 @@ const getUpdatedFieldMeta = (field: Field, prefillField?: TFieldMetaPrefillField
type: 'radio',
label: field.label,
values: newValues,
direction: radioMeta.direction ?? 'vertical',
};
return meta;
@ -395,8 +396,6 @@ export const createDocumentFromTemplate = async ({
};
});
const firstEnvelopeItemId = template.envelopeItems[0].id;
// Key = original envelope item ID
// Value = duplicated envelope item ID.
const oldEnvelopeItemToNewEnvelopeItemIdMap: Record<string, string> = {};
@ -407,10 +406,14 @@ export const createDocumentFromTemplate = async ({
template.envelopeItems.map(async (item, i) => {
let documentDataIdToDuplicate = item.documentDataId;
const foundCustomDocumentData = customDocumentData.find(
(customDocumentDataItem) =>
customDocumentDataItem.envelopeItemId || firstEnvelopeItemId === item.id,
);
const foundCustomDocumentData = customDocumentData.find((customDocumentDataItem) => {
// Handle empty envelopeItemId for backwards compatibility reasons.
if (customDocumentDataItem.documentDataId && !customDocumentDataItem.envelopeItemId) {
return true;
}
return customDocumentDataItem.envelopeItemId === item.id;
});
if (foundCustomDocumentData) {
documentDataIdToDuplicate = foundCustomDocumentData.documentDataId;

View File

@ -38,7 +38,6 @@ export const getTemplateByDirectLinkToken = async ({
const directLink = envelope?.directLink;
// Todo: Envelopes
const firstDocumentData = envelope?.envelopeItems[0]?.documentData;
// Doing this to enforce type safety for directLink.

View File

@ -33,6 +33,7 @@ export const ZDocumentSchema = LegacyDocumentSchema.pick({
folderId: true,
}).extend({
envelopeId: z.string(),
internalVersion: z.number(),
// Which "Template" the document was created from.
templateId: z
@ -114,6 +115,7 @@ export const ZDocumentLiteSchema = LegacyDocumentSchema.pick({
useLegacyFieldInsertion: true,
}).extend({
envelopeId: z.string(),
internalVersion: z.number(),
// Backwards compatibility.
documentDataId: z.string().default(''),
@ -149,6 +151,7 @@ export const ZDocumentManySchema = LegacyDocumentSchema.pick({
useLegacyFieldInsertion: true,
}).extend({
envelopeId: z.string(),
internalVersion: z.number(),
// Backwards compatibility.
documentDataId: z.string().default(''),

View File

@ -79,7 +79,7 @@ export const ZEnvelopeSchema = EnvelopeSchema.pick({
type: true,
id: true,
data: true,
initialData: true, // Todo: Envelopes - Maybe this hide this.
initialData: true,
}),
})
.array(),

View File

@ -1,6 +1,8 @@
import { FieldType } from '@prisma/client';
import { z } from 'zod';
import { DEFAULT_SIGNATURE_TEXT_FONT_SIZE } from '../constants/pdf';
export const DEFAULT_FIELD_FONT_SIZE = 14;
export const ZBaseFieldMeta = z.object({
@ -8,6 +10,7 @@ export const ZBaseFieldMeta = z.object({
placeholder: z.string().optional(),
required: z.boolean().optional(),
readOnly: z.boolean().optional(),
fontSize: z.number().min(8).max(96).default(DEFAULT_FIELD_FONT_SIZE).optional(),
});
export type TBaseFieldMeta = z.infer<typeof ZBaseFieldMeta>;
@ -18,7 +21,6 @@ export type TFieldTextAlignSchema = z.infer<typeof ZFieldTextAlignSchema>;
export const ZInitialsFieldMeta = ZBaseFieldMeta.extend({
type: z.literal('initials'),
fontSize: z.number().min(8).max(96).optional(),
textAlign: ZFieldTextAlignSchema.optional(),
});
@ -26,7 +28,6 @@ export type TInitialsFieldMeta = z.infer<typeof ZInitialsFieldMeta>;
export const ZNameFieldMeta = ZBaseFieldMeta.extend({
type: z.literal('name'),
fontSize: z.number().min(8).max(96).optional(),
textAlign: ZFieldTextAlignSchema.optional(),
});
@ -34,7 +35,6 @@ export type TNameFieldMeta = z.infer<typeof ZNameFieldMeta>;
export const ZEmailFieldMeta = ZBaseFieldMeta.extend({
type: z.literal('email'),
fontSize: z.number().min(8).max(96).optional(),
textAlign: ZFieldTextAlignSchema.optional(),
});
@ -42,7 +42,6 @@ export type TEmailFieldMeta = z.infer<typeof ZEmailFieldMeta>;
export const ZDateFieldMeta = ZBaseFieldMeta.extend({
type: z.literal('date'),
fontSize: z.number().min(8).max(96).optional(),
textAlign: ZFieldTextAlignSchema.optional(),
});
@ -52,7 +51,6 @@ export const ZTextFieldMeta = ZBaseFieldMeta.extend({
type: z.literal('text'),
text: z.string().optional(),
characterLimit: z.number().optional(),
fontSize: z.number().min(8).max(96).optional(),
textAlign: ZFieldTextAlignSchema.optional(),
});
@ -64,7 +62,6 @@ export const ZNumberFieldMeta = ZBaseFieldMeta.extend({
value: z.string().optional(),
minValue: z.coerce.number().nullish(),
maxValue: z.coerce.number().nullish(),
fontSize: z.number().min(8).max(96).optional(),
textAlign: ZFieldTextAlignSchema.optional(),
});
@ -81,6 +78,7 @@ export const ZRadioFieldMeta = ZBaseFieldMeta.extend({
}),
)
.optional(),
direction: z.enum(['vertical', 'horizontal']).optional().default('vertical'),
});
export type TRadioFieldMeta = z.infer<typeof ZRadioFieldMeta>;
@ -111,7 +109,14 @@ export const ZDropdownFieldMeta = ZBaseFieldMeta.extend({
export type TDropdownFieldMeta = z.infer<typeof ZDropdownFieldMeta>;
export const ZSignatureFieldMeta = ZBaseFieldMeta.extend({
type: z.literal('signature'),
});
export type TSignatureFieldMeta = z.infer<typeof ZSignatureFieldMeta>;
export const ZFieldMetaNotOptionalSchema = z.discriminatedUnion('type', [
ZSignatureFieldMeta,
ZInitialsFieldMeta,
ZNameFieldMeta,
ZEmailFieldMeta,
@ -231,13 +236,13 @@ export type TFieldAndMeta = z.infer<typeof ZFieldAndMetaSchema>;
export const FIELD_DATE_META_DEFAULT_VALUES: TDateFieldMeta = {
type: 'date',
fontSize: 14,
fontSize: DEFAULT_FIELD_FONT_SIZE,
textAlign: 'left',
};
export const FIELD_TEXT_META_DEFAULT_VALUES: TTextFieldMeta = {
type: 'text',
fontSize: 14,
fontSize: DEFAULT_FIELD_FONT_SIZE,
textAlign: 'left',
label: '',
placeholder: '',
@ -245,9 +250,10 @@ export const FIELD_TEXT_META_DEFAULT_VALUES: TTextFieldMeta = {
required: false,
readOnly: false,
};
export const FIELD_NUMBER_META_DEFAULT_VALUES: TNumberFieldMeta = {
type: 'number',
fontSize: 14,
fontSize: DEFAULT_FIELD_FONT_SIZE,
textAlign: 'left',
label: '',
placeholder: '',
@ -257,31 +263,34 @@ export const FIELD_NUMBER_META_DEFAULT_VALUES: TNumberFieldMeta = {
export const FIELD_INITIALS_META_DEFAULT_VALUES: TInitialsFieldMeta = {
type: 'initials',
fontSize: 14,
fontSize: DEFAULT_FIELD_FONT_SIZE,
textAlign: 'left',
};
export const FIELD_NAME_META_DEFAULT_VALUES: TNameFieldMeta = {
type: 'name',
fontSize: 14,
fontSize: DEFAULT_FIELD_FONT_SIZE,
textAlign: 'left',
};
export const FIELD_EMAIL_META_DEFAULT_VALUES: TEmailFieldMeta = {
type: 'email',
fontSize: 14,
fontSize: DEFAULT_FIELD_FONT_SIZE,
textAlign: 'left',
};
export const FIELD_RADIO_META_DEFAULT_VALUES: TRadioFieldMeta = {
type: 'radio',
fontSize: DEFAULT_FIELD_FONT_SIZE,
values: [{ id: 1, checked: false, value: '' }],
required: false,
readOnly: false,
direction: 'vertical',
};
export const FIELD_CHECKBOX_META_DEFAULT_VALUES: TCheckboxFieldMeta = {
type: 'checkbox',
fontSize: DEFAULT_FIELD_FONT_SIZE,
values: [{ id: 1, checked: false, value: '' }],
validationRule: '',
validationLength: 0,
@ -292,14 +301,20 @@ export const FIELD_CHECKBOX_META_DEFAULT_VALUES: TCheckboxFieldMeta = {
export const FIELD_DROPDOWN_META_DEFAULT_VALUES: TDropdownFieldMeta = {
type: 'dropdown',
fontSize: DEFAULT_FIELD_FONT_SIZE,
values: [{ value: 'Option 1' }],
defaultValue: '',
required: false,
readOnly: false,
};
export const FIELD_SIGNATURE_META_DEFAULT_VALUES: TSignatureFieldMeta = {
type: 'signature',
fontSize: DEFAULT_SIGNATURE_TEXT_FONT_SIZE,
};
export const FIELD_META_DEFAULT_VALUES: Record<FieldType, TFieldMetaSchema> = {
[FieldType.SIGNATURE]: undefined,
[FieldType.SIGNATURE]: FIELD_SIGNATURE_META_DEFAULT_VALUES,
[FieldType.FREE_SIGNATURE]: undefined,
[FieldType.INITIALS]: FIELD_INITIALS_META_DEFAULT_VALUES,
[FieldType.NAME]: FIELD_NAME_META_DEFAULT_VALUES,

View File

@ -4,6 +4,7 @@ import { z } from 'zod';
import { FieldSchema } from '@documenso/prisma/generated/zod/modelSchema/FieldSchema';
import {
FIELD_SIGNATURE_META_DEFAULT_VALUES,
ZCheckboxFieldMeta,
ZDateFieldMeta,
ZDropdownFieldMeta,
@ -12,6 +13,7 @@ import {
ZNameFieldMeta,
ZNumberFieldMeta,
ZRadioFieldMeta,
ZSignatureFieldMeta,
ZTextFieldMeta,
} from './field-meta';
@ -91,7 +93,7 @@ export type TFieldText = z.infer<typeof ZFieldTextSchema>;
export const ZFieldSignatureSchema = BaseFieldSchemaUsingNumbers.extend({
type: z.literal(FieldType.SIGNATURE),
fieldMeta: z.literal(null),
fieldMeta: ZSignatureFieldMeta.catch(FIELD_SIGNATURE_META_DEFAULT_VALUES),
});
export type TFieldSignature = z.infer<typeof ZFieldSignatureSchema>;

View File

@ -30,6 +30,8 @@ export const ZClaimFlagsSchema = z.object({
cfr21: z.boolean().optional(),
authenticationPortal: z.boolean().optional(),
allowEnvelopes: z.boolean().optional(),
});
export type TClaimFlags = z.infer<typeof ZClaimFlagsSchema>;
@ -82,6 +84,10 @@ export const SUBSCRIPTION_CLAIM_FEATURE_FLAGS: Record<
key: 'authenticationPortal',
label: 'Authentication portal',
},
allowEnvelopes: {
key: 'allowEnvelopes',
label: 'Allow envelopes',
},
};
export enum INTERNAL_CLAIM_ID {
@ -105,6 +111,7 @@ export const internalClaims: InternalClaims = {
name: 'Free',
teamCount: 1,
memberCount: 1,
envelopeItemCount: 5,
locked: true,
flags: {},
},
@ -113,6 +120,7 @@ export const internalClaims: InternalClaims = {
name: 'Individual',
teamCount: 1,
memberCount: 1,
envelopeItemCount: 5,
locked: true,
flags: {
unlimitedDocuments: true,
@ -123,6 +131,7 @@ export const internalClaims: InternalClaims = {
name: 'Teams',
teamCount: 1,
memberCount: 5,
envelopeItemCount: 5,
locked: true,
flags: {
unlimitedDocuments: true,
@ -135,6 +144,7 @@ export const internalClaims: InternalClaims = {
name: 'Platform',
teamCount: 1,
memberCount: 0,
envelopeItemCount: 10,
locked: true,
flags: {
unlimitedDocuments: true,
@ -152,6 +162,7 @@ export const internalClaims: InternalClaims = {
name: 'Enterprise',
teamCount: 0,
memberCount: 0,
envelopeItemCount: 10,
locked: true,
flags: {
unlimitedDocuments: true,
@ -171,6 +182,7 @@ export const internalClaims: InternalClaims = {
name: 'Early Adopter',
teamCount: 0,
memberCount: 0,
envelopeItemCount: 5,
locked: true,
flags: {
unlimitedDocuments: true,

View File

@ -8,11 +8,15 @@ import {
import type { FieldToRender, RenderFieldElementOptions } from './field-renderer';
import { calculateFieldPosition } from './field-renderer';
export const konvaTextFontFamily =
'Noto Sans, Noto Sans Japanese, Noto Sans Chinese, Noto Sans Korean, sans-serif';
export const konvaTextFill = 'black';
export const upsertFieldGroup = (
field: FieldToRender,
options: RenderFieldElementOptions,
): Konva.Group => {
const { pageWidth, pageHeight, pageLayer, editable } = options;
const { pageWidth, pageHeight, pageLayer, editable, scale } = options;
const { fieldX, fieldY, fieldWidth, fieldHeight } = calculateFieldPosition(
field,
@ -27,6 +31,9 @@ export const upsertFieldGroup = (
name: 'field-group',
});
const maxXPosition = (pageWidth - fieldWidth) * scale;
const maxYPosition = (pageHeight - fieldHeight) * scale;
fieldGroup.setAttrs({
scaleX: 1,
scaleY: 1,
@ -34,8 +41,9 @@ export const upsertFieldGroup = (
y: fieldY,
draggable: editable,
dragBoundFunc: (pos) => {
const newX = Math.max(0, Math.min(pageWidth - fieldWidth, pos.x));
const newY = Math.max(0, Math.min(pageHeight - fieldHeight, pos.y));
const newX = Math.max(0, Math.min(maxXPosition, pos.x));
const newY = Math.max(0, Math.min(maxYPosition, pos.y));
return { x: newX, y: newY };
},
} satisfies Partial<Konva.GroupConfig>);
@ -89,14 +97,18 @@ export const createSpinner = ({
width: fieldWidth - 8,
height: fieldHeight - 8,
fill: 'white',
opacity: 1,
opacity: 0.8,
});
const maxSpinnerSize = 10;
const smallerDimension = Math.min(fieldWidth, fieldHeight);
const spinnerSize = Math.min(smallerDimension, maxSpinnerSize);
const spinner = new Konva.Arc({
x: fieldWidth / 2,
y: fieldHeight / 2,
innerRadius: fieldWidth / 10,
outerRadius: fieldHeight / 10,
innerRadius: spinnerSize,
outerRadius: spinnerSize / 2,
angle: 270,
rotation: 0,
fill: 'rgba(122, 195, 85, 1)',

View File

@ -1,4 +1,4 @@
import type { Signature } from '@prisma/client';
import type { FieldType, Signature } from '@prisma/client';
import { type Field } from '@prisma/client';
import type Konva from 'konva';
@ -26,9 +26,11 @@ export type RenderFieldElementOptions = {
pageLayer: Konva.Layer;
pageWidth: number;
pageHeight: number;
mode?: 'edit' | 'sign' | 'export';
mode: 'edit' | 'sign' | 'export';
editable?: boolean;
scale: number;
color?: TRecipientColor;
translations: Record<FieldType, string> | null;
};
/**
@ -107,6 +109,11 @@ type CalculateMultiItemPositionOptions = {
*/
fieldPadding: number;
/**
* The direction of the items.
*/
direction: 'horizontal' | 'vertical';
type: 'checkbox' | 'radio';
};
@ -122,6 +129,7 @@ export const calculateMultiItemPosition = (options: CalculateMultiItemPositionOp
itemSize,
spacingBetweenItemAndText,
fieldPadding,
direction,
type,
} = options;
@ -130,6 +138,39 @@ export const calculateMultiItemPosition = (options: CalculateMultiItemPositionOp
const innerFieldX = fieldPadding;
const innerFieldY = fieldPadding;
if (direction === 'horizontal') {
const itemHeight = innerFieldHeight;
const itemWidth = innerFieldWidth / itemCount;
const y = innerFieldY;
const x = itemIndex * itemWidth + innerFieldX;
let itemInputY = y + itemHeight / 2 - itemSize / 2;
let itemInputX = x;
// We need a little different logic to center the radio circle icon.
if (type === 'radio') {
itemInputX = x + itemSize / 2;
itemInputY = y + itemHeight / 2;
}
const textX = x + itemSize + spacingBetweenItemAndText;
const textY = y;
// Multiplied by 2 for extra padding on the right hand side of the text and the next item.
const textWidth = itemWidth - itemSize - spacingBetweenItemAndText * 2;
const textHeight = itemHeight;
return {
itemInputX,
itemInputY,
textX,
textY,
textWidth,
textHeight,
};
}
const itemHeight = innerFieldHeight / itemCount;
const y = itemIndex * itemHeight + innerFieldY;
@ -137,6 +178,7 @@ export const calculateMultiItemPosition = (options: CalculateMultiItemPositionOp
let itemInputY = y + itemHeight / 2 - itemSize / 2;
let itemInputX = innerFieldX;
// We need a little different logic to center the radio circle icon.
if (type === 'radio') {
itemInputX = innerFieldX + itemSize / 2;
itemInputY = y + itemHeight / 2;

View File

@ -1,16 +1,25 @@
import Konva from 'konva';
import { match } from 'ts-pattern';
import { DEFAULT_STANDARD_FONT_SIZE } from '../../constants/pdf';
import type { TCheckboxFieldMeta } from '../../types/field-meta';
import { upsertFieldGroup, upsertFieldRect } from './field-generic-items';
import {
konvaTextFill,
konvaTextFontFamily,
upsertFieldGroup,
upsertFieldRect,
} from './field-generic-items';
import { calculateFieldPosition, calculateMultiItemPosition } from './field-renderer';
import type { FieldToRender, RenderFieldElementOptions } from './field-renderer';
// Do not change any of these values without consulting with the team.
const checkboxFieldPadding = 8;
const checkboxSize = 16;
const spacingBetweenCheckboxAndText = 8;
const calculateCheckboxSize = (fontSize: number) => {
return fontSize;
};
export const renderCheckboxFieldElement = (
field: FieldToRender,
options: RenderFieldElementOptions,
@ -21,134 +30,156 @@ export const renderCheckboxFieldElement = (
const fieldGroup = upsertFieldGroup(field, options);
// Clear previous children to re-render fresh
// Clear previous children and listeners to re-render fresh.
fieldGroup.removeChildren();
fieldGroup.off('transform');
fieldGroup.add(upsertFieldRect(field, options));
if (isFirstRender) {
pageLayer.add(fieldGroup);
// Handle rescaling items during transforms.
fieldGroup.on('transform', () => {
const groupScaleX = fieldGroup.scaleX();
const groupScaleY = fieldGroup.scaleY();
const fieldRect = fieldGroup.findOne('.field-rect');
if (!fieldRect) {
return;
}
const rectWidth = fieldRect.width() * groupScaleX;
const rectHeight = fieldRect.height() * groupScaleY;
// Todo: Envelopes - check sorting more than 10
// arr.sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
const squares = fieldGroup
.find('.checkbox-square')
.sort((a, b) => a.id().localeCompare(b.id()));
const checkmarks = fieldGroup
.find('.checkbox-checkmark')
.sort((a, b) => a.id().localeCompare(b.id()));
const text = fieldGroup.find('.checkbox-text').sort((a, b) => a.id().localeCompare(b.id()));
const groupedItems = squares.map((square, i) => ({
squareElement: square,
checkmarkElement: checkmarks[i],
textElement: text[i],
}));
groupedItems.forEach((item, i) => {
const { squareElement, checkmarkElement, textElement } = item;
const { itemInputX, itemInputY, textX, textY, textWidth, textHeight } =
calculateMultiItemPosition({
fieldWidth: rectWidth,
fieldHeight: rectHeight,
itemCount: checkboxValues.length,
itemIndex: i,
itemSize: checkboxSize,
spacingBetweenItemAndText: spacingBetweenCheckboxAndText,
fieldPadding: checkboxFieldPadding,
type: 'checkbox',
});
squareElement.setAttrs({
x: itemInputX,
y: itemInputY,
scaleX: 1,
scaleY: 1,
});
checkmarkElement.setAttrs({
x: itemInputX,
y: itemInputY,
scaleX: 1,
scaleY: 1,
});
textElement.setAttrs({
x: textX,
y: textY,
scaleX: 1,
scaleY: 1,
width: textWidth,
height: textHeight,
});
});
fieldRect.setAttrs({
width: rectWidth,
height: rectHeight,
});
fieldGroup.scale({
x: 1,
y: 1,
});
pageLayer.batchDraw();
});
}
const checkboxMeta: TCheckboxFieldMeta | null = (field.fieldMeta as TCheckboxFieldMeta) || null;
const checkboxValues = checkboxMeta?.values || [];
const fontSize = checkboxMeta?.fontSize || DEFAULT_STANDARD_FONT_SIZE;
if (isFirstRender) {
pageLayer.add(fieldGroup);
}
// Handle rescaling items during transforms.
fieldGroup.on('transform', () => {
const groupScaleX = fieldGroup.scaleX();
const groupScaleY = fieldGroup.scaleY();
const fieldRect = fieldGroup.findOne('.field-rect');
if (!fieldRect) {
return;
}
const rectWidth = fieldRect.width() * groupScaleX;
const rectHeight = fieldRect.height() * groupScaleY;
// Todo: Envelopes - check sorting more than 10
// arr.sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
const squares = fieldGroup
.find('.checkbox-square')
.sort((a, b) => a.id().localeCompare(b.id()));
const checkmarks = fieldGroup
.find('.checkbox-checkmark')
.sort((a, b) => a.id().localeCompare(b.id()));
const text = fieldGroup.find('.checkbox-text').sort((a, b) => a.id().localeCompare(b.id()));
const groupedItems = squares.map((square, i) => ({
squareElement: square,
checkmarkElement: checkmarks[i],
textElement: text[i],
}));
groupedItems.forEach((item, i) => {
const { squareElement, checkmarkElement, textElement } = item;
const { itemInputX, itemInputY, textX, textY, textWidth, textHeight } =
calculateMultiItemPosition({
fieldWidth: rectWidth,
fieldHeight: rectHeight,
itemCount: checkboxValues.length,
itemIndex: i,
itemSize: calculateCheckboxSize(fontSize),
spacingBetweenItemAndText: spacingBetweenCheckboxAndText,
fieldPadding: checkboxFieldPadding,
direction: checkboxMeta?.direction || 'vertical',
type: 'checkbox',
});
squareElement.setAttrs({
x: itemInputX,
y: itemInputY,
scaleX: 1,
scaleY: 1,
});
checkmarkElement.setAttrs({
x: itemInputX,
y: itemInputY,
});
textElement.setAttrs({
x: textX,
y: textY,
scaleX: 1,
scaleY: 1,
width: textWidth,
height: textHeight,
});
});
fieldRect.setAttrs({
width: rectWidth,
height: rectHeight,
});
fieldGroup.scale({
x: 1,
y: 1,
});
pageLayer.batchDraw();
});
const { fieldWidth, fieldHeight } = calculateFieldPosition(field, pageWidth, pageHeight);
const checkedValues: number[] = field.customText ? JSON.parse(field.customText) : [];
checkboxValues.forEach(({ id, value, checked }, index) => {
const isCheckboxChecked = match(mode)
.with('edit', () => checked)
.with('sign', () => checkedValues.includes(index))
.with('export', () => {
// If it's read-only, check the originally checked state.
if (checkboxMeta.readOnly) {
return checked;
}
return checkedValues.includes(index);
})
.exhaustive();
console.log('wtf?');
const itemSize = calculateCheckboxSize(fontSize);
const { itemInputX, itemInputY, textX, textY, textWidth, textHeight } =
calculateMultiItemPosition({
fieldWidth,
fieldHeight,
itemCount: checkboxValues.length,
itemIndex: index,
itemSize: checkboxSize,
itemSize,
spacingBetweenItemAndText: spacingBetweenCheckboxAndText,
fieldPadding: checkboxFieldPadding,
direction: checkboxMeta?.direction || 'vertical',
type: 'checkbox',
});
const square = new Konva.Rect({
internalCheckboxId: id,
internalCheckboxValue: value,
internalCheckboxIndex: index,
id: `checkbox-square-${index}`,
name: 'checkbox-square',
x: itemInputX,
y: itemInputY,
width: checkboxSize,
height: checkboxSize,
width: itemSize,
height: itemSize,
stroke: '#374151',
strokeWidth: 2,
cornerRadius: 2,
fill: 'white',
});
const checkboxScale = itemSize / 16;
const checkmark = new Konva.Line({
internalCheckboxId: id,
internalCheckboxValue: value,
internalCheckboxIndex: index,
id: `checkbox-checkmark-${index}`,
name: 'checkbox-checkmark',
x: itemInputX,
@ -156,12 +187,12 @@ export const renderCheckboxFieldElement = (
strokeWidth: 2,
stroke: '#111827',
points: [3, 8, 7, 12, 13, 4],
visible: checked,
scale: { x: checkboxScale, y: checkboxScale },
visible: isCheckboxChecked,
});
const text = new Konva.Text({
internalCheckboxId: id,
internalCheckboxValue: value,
internalCheckboxIndex: index,
id: `checkbox-text-${index}`,
name: 'checkbox-text',
x: textX,
@ -169,10 +200,10 @@ export const renderCheckboxFieldElement = (
text: value,
width: textWidth,
height: textHeight,
fontSize: DEFAULT_STANDARD_FONT_SIZE,
fontFamily: 'Inter, system-ui, sans-serif',
fontSize,
fontFamily: konvaTextFontFamily,
fill: konvaTextFill,
verticalAlign: 'middle',
fill: '#111827', // Todo: Envelopes - Sort colours
});
fieldGroup.add(square);

View File

@ -2,7 +2,12 @@ import Konva from 'konva';
import { DEFAULT_STANDARD_FONT_SIZE } from '../../constants/pdf';
import type { TDropdownFieldMeta } from '../../types/field-meta';
import { upsertFieldGroup, upsertFieldRect } from './field-generic-items';
import {
konvaTextFill,
konvaTextFontFamily,
upsertFieldGroup,
upsertFieldRect,
} from './field-generic-items';
import { calculateFieldPosition } from './field-renderer';
import type { FieldToRender, RenderFieldElementOptions } from './field-renderer';
@ -26,7 +31,7 @@ const calculateDropdownPosition = (options: CalculateDropdownPositionOptions) =>
const textY = fieldPadding;
const arrowX = fieldWidth - arrowSize - fieldPadding;
const arrowY = fieldHeight / 2;
const arrowY = fieldHeight / 2 - arrowSize / 4;
return {
arrowX,
@ -111,6 +116,8 @@ export const renderDropdownFieldElement = (
const dropdownMeta: TDropdownFieldMeta | null = (field.fieldMeta as TDropdownFieldMeta) || null;
const { fieldWidth, fieldHeight } = calculateFieldPosition(field, pageWidth, pageHeight);
const fontSize = dropdownMeta?.fontSize || DEFAULT_STANDARD_FONT_SIZE;
// Todo: Envelopes - Translations
let selectedValue = 'Select Option';
@ -132,9 +139,9 @@ export const renderDropdownFieldElement = (
width: textWidth,
height: textHeight,
text: selectedValue,
fontSize: DEFAULT_STANDARD_FONT_SIZE,
fontFamily: 'Inter, system-ui, sans-serif',
fill: '#111827',
fontSize,
fontFamily: konvaTextFontFamily,
fill: konvaTextFill,
verticalAlign: 'middle',
});

View File

@ -36,6 +36,8 @@ type RenderFieldOptions = {
color?: TRecipientColor;
translations: Record<FieldType, string> | null;
/**
* The render type.
*
@ -47,15 +49,18 @@ type RenderFieldOptions = {
*/
mode: 'edit' | 'sign' | 'export';
scale: number;
editable?: boolean;
};
export const renderField = ({
field,
translations,
pageLayer,
pageWidth,
pageHeight,
mode,
scale,
editable,
color,
}: RenderFieldOptions) => {
@ -63,9 +68,11 @@ export const renderField = ({
pageLayer,
pageWidth,
pageHeight,
translations,
mode,
color,
editable,
scale,
};
return match(field.type)
@ -74,5 +81,5 @@ export const renderField = ({
.with(FieldType.RADIO, () => renderRadioFieldElement(field, options))
.with(FieldType.DROPDOWN, () => renderDropdownFieldElement(field, options))
.with(FieldType.SIGNATURE, () => renderSignatureFieldElement(field, options))
.otherwise(() => renderTextFieldElement(field, options)); // Todo
.otherwise(() => renderTextFieldElement(field, options)); // Todo: Envelopes
};

View File

@ -1,16 +1,25 @@
import Konva from 'konva';
import { match } from 'ts-pattern';
import { DEFAULT_STANDARD_FONT_SIZE } from '../../constants/pdf';
import type { TRadioFieldMeta } from '../../types/field-meta';
import { upsertFieldGroup, upsertFieldRect } from './field-generic-items';
import {
konvaTextFill,
konvaTextFontFamily,
upsertFieldGroup,
upsertFieldRect,
} from './field-generic-items';
import { calculateFieldPosition, calculateMultiItemPosition } from './field-renderer';
import type { FieldToRender, RenderFieldElementOptions } from './field-renderer';
// Do not change any of these values without consulting with the team.
const radioFieldPadding = 8;
const radioSize = 16;
const spacingBetweenRadioAndText = 8;
const calculateRadioSize = (fontSize: number) => {
return fontSize;
};
export const renderRadioFieldElement = (
field: FieldToRender,
options: RenderFieldElementOptions,
@ -26,110 +35,129 @@ export const renderRadioFieldElement = (
fieldGroup.add(upsertFieldRect(field, options));
if (isFirstRender) {
pageLayer.add(fieldGroup);
// Handle rescaling items during transforms.
fieldGroup.on('transform', () => {
const groupScaleX = fieldGroup.scaleX();
const groupScaleY = fieldGroup.scaleY();
const fieldRect = fieldGroup.findOne('.field-rect');
if (!fieldRect) {
return;
}
const rectWidth = fieldRect.width() * groupScaleX;
const rectHeight = fieldRect.height() * groupScaleY;
const circles = fieldGroup.find('.radio-circle').sort((a, b) => a.id().localeCompare(b.id()));
const checkmarks = fieldGroup.find('.radio-dot').sort((a, b) => a.id().localeCompare(b.id()));
const text = fieldGroup.find('.radio-text').sort((a, b) => a.id().localeCompare(b.id()));
const groupedItems = circles.map((circle, i) => ({
circleElement: circle,
checkmarkElement: checkmarks[i],
textElement: text[i],
}));
groupedItems.forEach((item, i) => {
const { circleElement, checkmarkElement, textElement } = item;
const { itemInputX, itemInputY, textX, textY, textWidth, textHeight } =
calculateMultiItemPosition({
fieldWidth: rectWidth,
fieldHeight: rectHeight,
itemCount: radioValues.length,
itemIndex: i,
itemSize: radioSize,
spacingBetweenItemAndText: spacingBetweenRadioAndText,
fieldPadding: radioFieldPadding,
type: 'radio',
});
circleElement.setAttrs({
x: itemInputX,
y: itemInputY,
scaleX: 1,
scaleY: 1,
});
checkmarkElement.setAttrs({
x: itemInputX,
y: itemInputY,
scaleX: 1,
scaleY: 1,
});
textElement.setAttrs({
x: textX,
y: textY,
scaleX: 1,
scaleY: 1,
width: textWidth,
height: textHeight,
});
});
fieldRect.width(rectWidth);
fieldRect.height(rectHeight);
fieldGroup.scale({
x: 1,
y: 1,
});
pageLayer.batchDraw();
});
}
const radioMeta: TRadioFieldMeta | null = (field.fieldMeta as TRadioFieldMeta) || null;
const radioValues = radioMeta?.values || [];
const fontSize = radioMeta?.fontSize || DEFAULT_STANDARD_FONT_SIZE;
if (isFirstRender) {
pageLayer.add(fieldGroup);
}
fieldGroup.off('transform');
// Handle rescaling items during transforms.
fieldGroup.on('transform', () => {
const groupScaleX = fieldGroup.scaleX();
const groupScaleY = fieldGroup.scaleY();
const fieldRect = fieldGroup.findOne('.field-rect');
if (!fieldRect) {
return;
}
const rectWidth = fieldRect.width() * groupScaleX;
const rectHeight = fieldRect.height() * groupScaleY;
const circles = fieldGroup.find('.radio-circle').sort((a, b) => a.id().localeCompare(b.id()));
const checkmarks = fieldGroup.find('.radio-dot').sort((a, b) => a.id().localeCompare(b.id()));
const text = fieldGroup.find('.radio-text').sort((a, b) => a.id().localeCompare(b.id()));
const groupedItems = circles.map((circle, i) => ({
circleElement: circle,
checkmarkElement: checkmarks[i],
textElement: text[i],
}));
groupedItems.forEach((item, i) => {
const { circleElement, checkmarkElement, textElement } = item;
const { itemInputX, itemInputY, textX, textY, textWidth, textHeight } =
calculateMultiItemPosition({
fieldWidth: rectWidth,
fieldHeight: rectHeight,
itemCount: radioValues.length,
itemIndex: i,
itemSize: calculateRadioSize(fontSize),
spacingBetweenItemAndText: spacingBetweenRadioAndText,
fieldPadding: radioFieldPadding,
type: 'radio',
direction: radioMeta?.direction || 'vertical',
});
circleElement.setAttrs({
x: itemInputX,
y: itemInputY,
scaleX: 1,
scaleY: 1,
});
checkmarkElement.setAttrs({
x: itemInputX,
y: itemInputY,
scaleX: 1,
scaleY: 1,
});
textElement.setAttrs({
x: textX,
y: textY,
scaleX: 1,
scaleY: 1,
width: textWidth,
height: textHeight,
});
});
fieldRect.width(rectWidth);
fieldRect.height(rectHeight);
fieldGroup.scale({
x: 1,
y: 1,
});
pageLayer.batchDraw();
});
const { fieldWidth, fieldHeight } = calculateFieldPosition(field, pageWidth, pageHeight);
radioValues.forEach(({ value, checked }, index) => {
const isRadioValueChecked = match(mode)
.with('edit', () => checked)
.with('sign', () => index.toString() === field.customText)
.with('export', () => {
// If it's read-only, check the originally checked state.
if (radioMeta.readOnly) {
return checked;
}
return index.toString() === field.customText;
})
.exhaustive();
const { itemInputX, itemInputY, textX, textY, textWidth, textHeight } =
calculateMultiItemPosition({
fieldWidth,
fieldHeight,
itemCount: radioValues.length,
itemIndex: index,
itemSize: radioSize,
itemSize: calculateRadioSize(fontSize),
spacingBetweenItemAndText: spacingBetweenRadioAndText,
fieldPadding: radioFieldPadding,
type: 'radio',
direction: radioMeta?.direction || 'vertical',
});
// Circle which represents the radio button.
const circle = new Konva.Circle({
internalRadioValue: value,
internalRadioIndex: index,
id: `radio-circle-${index}`,
name: 'radio-circle',
x: itemInputX,
y: itemInputY,
radius: radioSize / 2,
radius: calculateRadioSize(fontSize) / 2,
stroke: '#374151',
strokeWidth: 2,
fill: 'white',
@ -137,20 +165,18 @@ export const renderRadioFieldElement = (
// Dot which represents the selected state.
const dot = new Konva.Circle({
internalRadioValue: value,
internalRadioIndex: index,
id: `radio-dot-${index}`,
name: 'radio-dot',
x: itemInputX,
y: itemInputY,
radius: radioSize / 4,
radius: calculateRadioSize(fontSize) / 4,
fill: '#111827',
// Todo: Envelopes
visible: value === field.customText,
// visible: checked,
visible: isRadioValueChecked,
});
const text = new Konva.Text({
internalRadioValue: value,
internalRadioIndex: index,
id: `radio-text-${index}`,
name: 'radio-text',
x: textX,
@ -158,10 +184,10 @@ export const renderRadioFieldElement = (
text: value,
width: textWidth,
height: textHeight,
fontSize: DEFAULT_STANDARD_FONT_SIZE,
fontFamily: 'Inter, system-ui, sans-serif',
fontSize,
fontFamily: konvaTextFontFamily,
fill: konvaTextFill,
verticalAlign: 'middle',
fill: '#111827', // Todo: Envelopes - Sort colours
});
fieldGroup.add(circle);

View File

@ -5,58 +5,74 @@ import {
RECIPIENT_COLOR_STYLES,
} from '@documenso/ui/lib/recipient-colors';
import {
DEFAULT_HANDWRITING_FONT_SIZE,
DEFAULT_STANDARD_FONT_SIZE,
MIN_HANDWRITING_FONT_SIZE,
} from '../../constants/pdf';
import { DEFAULT_SIGNATURE_TEXT_FONT_SIZE } from '../../constants/pdf';
import { AppError } from '../../errors/app-error';
import { upsertFieldGroup, upsertFieldRect } from './field-generic-items';
import { calculateFieldPosition } from './field-renderer';
import type { FieldToRender, RenderFieldElementOptions } from './field-renderer';
const minFontSize = MIN_HANDWRITING_FONT_SIZE;
const maxFontSize = DEFAULT_HANDWRITING_FONT_SIZE;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let SkiaImage: any = undefined;
const upsertFieldText = (field: FieldToRender, options: RenderFieldElementOptions): Konva.Text => {
const { pageWidth, pageHeight, mode = 'edit', pageLayer } = options;
void (async () => {
if (typeof window === 'undefined') {
const mod = await import('skia-canvas');
SkiaImage = mod.Image;
}
})();
console.log({
pageWidth,
pageHeight,
});
const getImageDimensions = (img: HTMLImageElement, fieldWidth: number, fieldHeight: number) => {
let imageWidth = img.width;
let imageHeight = img.height;
const scalingFactor = Math.min(fieldWidth / imageWidth, fieldHeight / imageHeight, 1);
imageWidth = imageWidth * scalingFactor;
imageHeight = imageHeight * scalingFactor;
const imageX = (fieldWidth - imageWidth) / 2;
const imageY = (fieldHeight - imageHeight) / 2;
return {
width: imageWidth,
height: imageHeight,
x: imageX,
y: imageY,
};
};
const createFieldSignature = (
field: FieldToRender,
options: RenderFieldElementOptions,
): Konva.Text | Konva.Image => {
const { pageWidth, pageHeight, mode = 'edit', translations } = options;
const { fieldWidth, fieldHeight } = calculateFieldPosition(field, pageWidth, pageHeight);
const fontSize = field.fieldMeta?.fontSize || DEFAULT_SIGNATURE_TEXT_FONT_SIZE;
const fieldText: Konva.Text =
pageLayer.findOne(`#${field.renderId}-text`) ||
new Konva.Text({
id: `${field.renderId}-text`,
name: 'field-text',
});
const fieldText = new Konva.Text({
id: `${field.renderId}-text`,
name: 'field-text',
});
const fieldTypeName = translations?.[field.type] || field.type;
// Calculate text positioning based on alignment
const textX = 0;
const textY = 0;
const textAlign = 'center';
let textVerticalAlign: 'top' | 'middle' | 'bottom' = 'top';
let textFontSize = DEFAULT_STANDARD_FONT_SIZE;
const textPadding = 10;
let textToRender: string = field.type;
let textToRender: string = fieldTypeName;
const signature = field.signature;
// Handle edit mode.
if (mode === 'edit') {
textToRender = field.type; // Todo: Envelope - Need translations
textToRender = fieldTypeName;
}
// Handle sign mode.
if (mode === 'sign' || mode === 'export') {
textToRender = field.type; // Todo: Envelope - Need translations
textFontSize = DEFAULT_STANDARD_FONT_SIZE;
textVerticalAlign = 'middle';
textToRender = fieldTypeName;
if (field.inserted && !signature) {
throw new AppError('MISSING_SIGNATURE');
@ -65,20 +81,57 @@ const upsertFieldText = (field: FieldToRender, options: RenderFieldElementOption
if (signature?.typedSignature) {
textToRender = signature.typedSignature;
}
if (signature?.signatureImageAsBase64) {
if (typeof window !== 'undefined') {
// Create a new HTML Image element
const img = new Image();
const image = new Konva.Image({
image: img,
x: 0,
y: 0,
width: fieldWidth,
height: fieldHeight,
});
img.onload = () => {
image.setAttrs({
...getImageDimensions(img, fieldWidth, fieldHeight),
});
};
img.src = signature.signatureImageAsBase64;
return image;
} else {
// Node.js with skia-canvas
if (!SkiaImage) {
throw new Error('Skia image not found');
}
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const img = new SkiaImage(signature?.signatureImageAsBase64) as unknown as HTMLImageElement;
const image = new Konva.Image({
image: img,
...getImageDimensions(img, fieldWidth, fieldHeight),
});
return image;
}
}
}
fieldText.setAttrs({
x: textX,
y: textY,
verticalAlign: textVerticalAlign,
wrap: 'word',
padding: textPadding,
verticalAlign: 'middle',
wrap: 'char',
text: textToRender,
fontSize: textFontSize,
fontFamily: 'Caveat, Inter', // Todo: Envelopes - Fix all fonts for sans
align: textAlign,
fontSize,
fontFamily: 'Caveat, sans-serif',
align: 'center',
width: fieldWidth,
height: fieldHeight,
} satisfies Partial<Konva.TextConfig>);
@ -96,78 +149,63 @@ export const renderSignatureFieldElement = (
const fieldGroup = upsertFieldGroup(field, options);
// ABOVE IS GENERIC, EXTRACT IT.
// Render the field background and text.
const fieldRect = upsertFieldRect(field, options);
const fieldText = upsertFieldText(field, options);
// Clear previous children and listeners to re-render fresh.
fieldGroup.removeChildren();
fieldGroup.off('transform');
// Assign elements to group and any listeners that should only be run on initialization.
if (isFirstRender) {
fieldGroup.add(fieldRect);
fieldGroup.add(fieldText);
pageLayer.add(fieldGroup);
// This is to keep the text inside the field at the same size
// when the field is resized. Without this the text would be stretched.
fieldGroup.on('transform', () => {
const groupScaleX = fieldGroup.scaleX();
const groupScaleY = fieldGroup.scaleY();
// Adjust text scale so it doesn't change while group is resized.
fieldText.scaleX(1 / groupScaleX);
fieldText.scaleY(1 / groupScaleY);
const rectWidth = fieldRect.width() * groupScaleX;
const rectHeight = fieldRect.height() * groupScaleY;
// // Update text group position and clipping
// fieldGroup.clipFunc(function (ctx) {
// ctx.rect(0, 0, rectWidth, rectHeight);
// });
// Update text dimensions
fieldText.width(rectWidth); // Account for padding
fieldText.height(rectHeight);
console.log({
rectWidth,
});
// Force Konva to recalculate text layout
// textInsideField.getTextHeight(); // This forces recalculation
fieldText.height(); // This forces recalculation
// fieldGroup.draw();
fieldGroup.getLayer()?.batchDraw();
});
// Reset the text after transform has ended.
fieldGroup.on('transformend', () => {
fieldText.scaleX(1);
fieldText.scaleY(1);
const rectWidth = fieldRect.width();
const rectHeight = fieldRect.height();
// // Update text group position and clipping
// fieldGroup.clipFunc(function (ctx) {
// ctx.rect(0, 0, rectWidth, rectHeight);
// });
// Update text dimensions
fieldText.width(rectWidth); // Account for padding
fieldText.height(rectHeight);
// Force Konva to recalculate text layout
// textInsideField.getTextHeight(); // This forces recalculation
fieldText.height(); // This forces recalculation
// fieldGroup.draw();
fieldGroup.getLayer()?.batchDraw();
});
}
// Render the field background and text.
const fieldRect = upsertFieldRect(field, options);
const fieldSignature = createFieldSignature(field, options);
fieldGroup.add(fieldRect);
fieldGroup.add(fieldSignature);
// This is to keep the text inside the field at the same size
// when the field is resized. Without this the text would be stretched.
fieldGroup.on('transform', () => {
const groupScaleX = fieldGroup.scaleX();
const groupScaleY = fieldGroup.scaleY();
// Adjust text scale so it doesn't change while group is resized.
fieldSignature.scaleX(1 / groupScaleX);
fieldSignature.scaleY(1 / groupScaleY);
const rectWidth = fieldRect.width() * groupScaleX;
const rectHeight = fieldRect.height() * groupScaleY;
// Update text dimensions
fieldSignature.width(rectWidth);
fieldSignature.height(rectHeight);
// Force Konva to recalculate text layout
fieldSignature.height();
fieldGroup.getLayer()?.batchDraw();
});
// Reset the text after transform has ended.
fieldGroup.on('transformend', () => {
fieldSignature.scaleX(1);
fieldSignature.scaleY(1);
const rectWidth = fieldRect.width();
const rectHeight = fieldRect.height();
// Update text dimensions
fieldSignature.width(rectWidth); // Account for padding
fieldSignature.height(rectHeight);
// Force Konva to recalculate text layout
fieldSignature.height();
fieldGroup.getLayer()?.batchDraw();
});
// Handle export mode.
if (mode === 'export') {
// Hide the rectangle.

View File

@ -7,12 +7,19 @@ import {
import { DEFAULT_STANDARD_FONT_SIZE } from '../../constants/pdf';
import type { TTextFieldMeta } from '../../types/field-meta';
import { upsertFieldGroup, upsertFieldRect } from './field-generic-items';
import {
konvaTextFill,
konvaTextFontFamily,
upsertFieldGroup,
upsertFieldRect,
} from './field-generic-items';
import type { FieldToRender, RenderFieldElementOptions } from './field-renderer';
import { calculateFieldPosition } from './field-renderer';
const upsertFieldText = (field: FieldToRender, options: RenderFieldElementOptions): Konva.Text => {
const { pageWidth, pageHeight, mode = 'edit', pageLayer } = options;
const { pageWidth, pageHeight, mode = 'edit', pageLayer, translations } = options;
const fieldTypeName = translations?.[field.type] || field.type;
const { fieldWidth, fieldHeight } = calculateFieldPosition(field, pageWidth, pageHeight);
@ -30,24 +37,21 @@ const upsertFieldText = (field: FieldToRender, options: RenderFieldElementOption
const textY = 0;
let textAlign: 'left' | 'center' | 'right' = textMeta?.textAlign || 'left';
let textVerticalAlign: 'top' | 'middle' | 'bottom' = 'top';
let textFontSize = textMeta?.fontSize || DEFAULT_STANDARD_FONT_SIZE;
const textFontSize = textMeta?.fontSize || DEFAULT_STANDARD_FONT_SIZE;
const textPadding = 10;
let textToRender: string = field.type;
let textToRender: string = fieldTypeName;
// Handle edit mode.
if (mode === 'edit') {
textToRender = field.type; // Todo: Envelope - Need translations
textToRender = fieldTypeName;
textAlign = 'center';
textFontSize = DEFAULT_STANDARD_FONT_SIZE;
textVerticalAlign = 'middle';
if (textMeta?.label) {
textToRender = textMeta.label;
textFontSize = textMeta.fontSize || DEFAULT_STANDARD_FONT_SIZE;
} else if (textMeta?.text) {
textToRender = textMeta.text;
textFontSize = textMeta.fontSize || DEFAULT_STANDARD_FONT_SIZE;
textAlign = textMeta.textAlign || 'center'; // Todo: Envelopes - What is the default
// Todo: Envelopes - Handle this on signatures
@ -59,19 +63,16 @@ const upsertFieldText = (field: FieldToRender, options: RenderFieldElementOption
// Handle sign mode.
if (mode === 'sign' || mode === 'export') {
textToRender = field.type; // Todo: Envelope - Need translations
textToRender = fieldTypeName;
textAlign = 'center';
textFontSize = DEFAULT_STANDARD_FONT_SIZE;
textVerticalAlign = 'middle';
if (textMeta?.label) {
textToRender = textMeta.label;
textFontSize = textMeta.fontSize || DEFAULT_STANDARD_FONT_SIZE;
}
if (textMeta?.text) {
textToRender = textMeta.text;
textFontSize = textMeta.fontSize || DEFAULT_STANDARD_FONT_SIZE;
textAlign = textMeta.textAlign || 'center'; // Todo: Envelopes - What is the default
// Todo: Envelopes - Handle this on signatures
@ -82,7 +83,6 @@ const upsertFieldText = (field: FieldToRender, options: RenderFieldElementOption
if (field.inserted) {
textToRender = field.customText;
textFontSize = textMeta?.fontSize || DEFAULT_STANDARD_FONT_SIZE;
textAlign = textMeta?.textAlign || 'center'; // Todo: Envelopes - What is the default
// Todo: Envelopes - Handle this on signatures
@ -98,11 +98,10 @@ const upsertFieldText = (field: FieldToRender, options: RenderFieldElementOption
verticalAlign: textVerticalAlign,
wrap: 'word',
padding: textPadding,
text: textToRender,
fontSize: textFontSize,
fontFamily: 'Inter, system-ui, sans-serif',
fontFamily: konvaTextFontFamily,
fill: konvaTextFill,
align: textAlign,
width: fieldWidth,
height: fieldHeight,
@ -121,77 +120,62 @@ export const renderTextFieldElement = (
const fieldGroup = upsertFieldGroup(field, options);
// ABOVE IS GENERIC, EXTRACT IT.
// Clear previous children and listeners to re-render fresh.
fieldGroup.removeChildren();
fieldGroup.off('transform');
// Assign elements to group and any listeners that should only be run on initialization.
if (isFirstRender) {
pageLayer.add(fieldGroup);
}
// Render the field background and text.
const fieldRect = upsertFieldRect(field, options);
const fieldText = upsertFieldText(field, options);
// Assign elements to group and any listeners that should only be run on initialization.
if (isFirstRender) {
fieldGroup.add(fieldRect);
fieldGroup.add(fieldText);
pageLayer.add(fieldGroup);
fieldGroup.add(fieldRect);
fieldGroup.add(fieldText);
// This is to keep the text inside the field at the same size
// when the field is resized. Without this the text would be stretched.
fieldGroup.on('transform', () => {
const groupScaleX = fieldGroup.scaleX();
const groupScaleY = fieldGroup.scaleY();
// This is to keep the text inside the field at the same size
// when the field is resized. Without this the text would be stretched.
fieldGroup.on('transform', () => {
const groupScaleX = fieldGroup.scaleX();
const groupScaleY = fieldGroup.scaleY();
// Adjust text scale so it doesn't change while group is resized.
fieldText.scaleX(1 / groupScaleX);
fieldText.scaleY(1 / groupScaleY);
// Adjust text scale so it doesn't change while group is resized.
fieldText.scaleX(1 / groupScaleX);
fieldText.scaleY(1 / groupScaleY);
const rectWidth = fieldRect.width() * groupScaleX;
const rectHeight = fieldRect.height() * groupScaleY;
const rectWidth = fieldRect.width() * groupScaleX;
const rectHeight = fieldRect.height() * groupScaleY;
// // Update text group position and clipping
// fieldGroup.clipFunc(function (ctx) {
// ctx.rect(0, 0, rectWidth, rectHeight);
// });
// Update text dimensions
fieldText.width(rectWidth);
fieldText.height(rectHeight);
// Update text dimensions
fieldText.width(rectWidth); // Account for padding
fieldText.height(rectHeight);
// Force Konva to recalculate text layout
fieldText.height();
console.log({
rectWidth,
});
fieldGroup.getLayer()?.batchDraw();
});
// Force Konva to recalculate text layout
// textInsideField.getTextHeight(); // This forces recalculation
fieldText.height(); // This forces recalculation
// Reset the text after transform has ended.
fieldGroup.on('transformend', () => {
fieldText.scaleX(1);
fieldText.scaleY(1);
// fieldGroup.draw();
fieldGroup.getLayer()?.batchDraw();
});
const rectWidth = fieldRect.width();
const rectHeight = fieldRect.height();
// Reset the text after transform has ended.
fieldGroup.on('transformend', () => {
fieldText.scaleX(1);
fieldText.scaleY(1);
// Update text dimensions
fieldText.width(rectWidth); // Account for padding
fieldText.height(rectHeight);
const rectWidth = fieldRect.width();
const rectHeight = fieldRect.height();
// Force Konva to recalculate text layout
fieldText.height();
// // Update text group position and clipping
// fieldGroup.clipFunc(function (ctx) {
// ctx.rect(0, 0, rectWidth, rectHeight);
// });
// Update text dimensions
fieldText.width(rectWidth); // Account for padding
fieldText.height(rectHeight);
// Force Konva to recalculate text layout
// textInsideField.getTextHeight(); // This forces recalculation
fieldText.height(); // This forces recalculation
// fieldGroup.draw();
fieldGroup.getLayer()?.batchDraw();
});
}
fieldGroup.getLayer()?.batchDraw();
});
// Handle export mode.
if (mode === 'export') {

View File

@ -76,6 +76,7 @@ export const mapEnvelopeToDocumentLite = (envelope: Envelope): TDocumentLite =>
return {
id: documentId, // Use legacy ID.
envelopeId: envelope.id,
internalVersion: envelope.internalVersion,
visibility: envelope.visibility,
status: envelope.status,
source: envelope.source,
@ -115,6 +116,7 @@ export const mapEnvelopesToDocumentMany = (
return {
id: legacyDocumentId, // Use legacy ID.
envelopeId: envelope.id,
internalVersion: envelope.internalVersion,
visibility: envelope.visibility,
status: envelope.status,
source: envelope.source,

View File

@ -0,0 +1,263 @@
import type { Field } from '@prisma/client';
import { FieldType } from '@prisma/client';
import { DateTime } from 'luxon';
import { P, match } from 'ts-pattern';
import { z } from 'zod';
import { validateCheckboxLength } from '@documenso/lib/advanced-fields-validation/validate-checkbox';
import { validateDropdownField } from '@documenso/lib/advanced-fields-validation/validate-dropdown';
import { validateNumberField } from '@documenso/lib/advanced-fields-validation/validate-number';
import { validateTextField } from '@documenso/lib/advanced-fields-validation/validate-text';
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
import { isBase64Image } from '@documenso/lib/constants/signatures';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { TDocumentMeta } from '@documenso/lib/types/document-meta';
import {
ZCheckboxFieldMeta,
ZDropdownFieldMeta,
ZNumberFieldMeta,
ZRadioFieldMeta,
ZTextFieldMeta,
} from '@documenso/lib/types/field-meta';
import { toCheckboxCustomText, toRadioCustomText } from '@documenso/lib/utils/fields';
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';
export type ExtractFieldInsertionValuesOptions = {
fieldValue: TSignEnvelopeFieldValue;
field: Field;
documentMeta: Pick<TDocumentMeta, 'timezone' | 'dateFormat' | 'typedSignatureEnabled'>;
};
export const extractFieldInsertionValues = ({
fieldValue,
field,
documentMeta,
}: ExtractFieldInsertionValuesOptions): { customText: string; inserted: boolean } => {
return match(fieldValue)
.with({ type: FieldType.EMAIL }, (fieldValue) => {
const parsedEmailValue = z.string().email().nullable().safeParse(fieldValue.value);
if (!parsedEmailValue.success) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: 'Invalid email',
});
}
if (parsedEmailValue.data === null) {
return {
customText: '',
inserted: false,
};
}
return {
customText: parsedEmailValue.data,
inserted: true,
};
})
.with({ type: P.union(FieldType.NAME, FieldType.INITIALS) }, (fieldValue) => {
const parsedGenericStringValue = z.string().min(1).nullable().safeParse(fieldValue.value);
if (!parsedGenericStringValue.success) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: 'Value is required',
});
}
if (parsedGenericStringValue.data === null) {
return {
customText: '',
inserted: false,
};
}
return {
customText: parsedGenericStringValue.data,
inserted: true,
};
})
.with({ type: FieldType.DATE }, (fieldValue) => {
if (!fieldValue.value) {
return {
customText: '',
inserted: false,
};
}
return {
customText: DateTime.now()
.setZone(documentMeta.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE)
.toFormat(documentMeta.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT),
inserted: true,
};
})
.with({ type: FieldType.NUMBER }, (fieldValue) => {
if (!fieldValue.value) {
return {
customText: '',
inserted: false,
};
}
const numberFieldParsedMeta = ZNumberFieldMeta.parse(field.fieldMeta);
const errors = validateNumberField(fieldValue.value.toString(), numberFieldParsedMeta, true);
// Todo
if (errors.length > 0) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: 'Invalid number',
});
}
return {
customText: fieldValue.value.toString(),
inserted: true,
};
})
.with({ type: FieldType.TEXT }, (fieldValue) => {
if (fieldValue.value === null) {
return {
customText: '',
inserted: false,
};
}
const parsedTextFieldMeta = ZTextFieldMeta.parse(field.fieldMeta);
const errors = validateTextField(fieldValue.value, parsedTextFieldMeta, true);
// Todo
if (errors.length > 0) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: 'Invalid email',
});
}
return {
customText: fieldValue.value,
inserted: true,
};
})
.with({ type: FieldType.RADIO }, (fieldValue) => {
if (fieldValue.value === null) {
return {
customText: '',
inserted: false,
};
}
const parsedRadioFieldParsedMeta = ZRadioFieldMeta.parse(field.fieldMeta);
const radioFieldValues = parsedRadioFieldParsedMeta.values || [];
if (!radioFieldValues[fieldValue.value]) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: 'Invalid radio value',
});
}
return {
customText: toRadioCustomText(fieldValue.value),
inserted: true,
};
})
.with({ type: FieldType.CHECKBOX }, (fieldValue) => {
if (fieldValue.value === null || fieldValue.value.length === 0) {
return {
customText: '',
inserted: false,
};
}
const parsedCheckboxFieldParsedMeta = ZCheckboxFieldMeta.parse(field.fieldMeta);
const checkboxFieldValues = parsedCheckboxFieldParsedMeta.values || [];
const { value } = fieldValue;
const selectedValues = value.map((valueIndex) => checkboxFieldValues[valueIndex]);
if (selectedValues.some((value) => !value)) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: 'Invalid checkbox values',
});
}
const { validationRule, validationLength } = parsedCheckboxFieldParsedMeta;
if (validationRule && validationLength) {
const checkboxValidationRule = checkboxValidationSigns.find(
(sign) => sign.label === validationRule,
);
// Todo: Envelopes - Test this.
if (checkboxValidationRule) {
const isValid = validateCheckboxLength(
selectedValues.length,
checkboxValidationRule.value,
validationLength,
);
if (!isValid) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: 'Checkbox values failed length validation',
});
}
} else {
// Should throw an error, but we don't want to throw configuration errors during signing.
// Todo: Logging.
}
}
return {
customText: toCheckboxCustomText(fieldValue.value),
inserted: true,
};
})
.with({ type: FieldType.DROPDOWN }, (fieldValue) => {
if (fieldValue.value === null) {
return {
customText: '',
inserted: false,
};
}
const parsedDropdownFieldMeta = ZDropdownFieldMeta.parse(field.fieldMeta);
const errors = validateDropdownField(fieldValue.value, parsedDropdownFieldMeta, true);
// Todo: Envelopes
if (errors.length > 0) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: 'Invalid dropdown value',
});
}
return {
customText: fieldValue.value,
inserted: true,
};
})
.with({ type: FieldType.SIGNATURE }, (fieldValue) => {
const { value } = fieldValue;
if (!value) {
return {
customText: '',
inserted: false,
};
}
const isBase64 = isBase64Image(value);
if (documentMeta.typedSignatureEnabled === false && !isBase64) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: 'Typed signatures are not allowed. Please draw your signature',
});
}
return {
customText: '',
inserted: true,
};
})
.exhaustive();
};

View File

@ -1,4 +1,6 @@
import { type Envelope, type Field } from '@prisma/client';
import type { I18n } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { type Envelope, type Field, FieldType } from '@prisma/client';
import { extractLegacyIds } from '../universal/id';
@ -77,3 +79,35 @@ export const mapFieldToLegacyField = (
...legacyId,
};
};
export const parseCheckboxCustomText = (customText: string): number[] => {
return JSON.parse(customText);
};
export const toCheckboxCustomText = (checkedValues: number[]): string => {
return JSON.stringify(checkedValues);
};
export const parseRadioCustomText = (customText: string): number => {
return Number(customText);
};
export const toRadioCustomText = (value: number): string => {
return value.toString();
};
export const getClientSideFieldTranslations = ({ t }: I18n): Record<FieldType, string> => {
return {
[FieldType.TEXT]: t(msg`Text`),
[FieldType.CHECKBOX]: t(msg`Checkbox`),
[FieldType.RADIO]: t(msg`Radio`),
[FieldType.DROPDOWN]: t(msg`Dropdown`),
[FieldType.SIGNATURE]: t(msg`Signature`),
[FieldType.FREE_SIGNATURE]: t(msg`Free Signature`),
[FieldType.INITIALS]: t(msg`Initials`),
[FieldType.NAME]: t(msg`Name`),
[FieldType.NUMBER]: t(msg`Number`),
[FieldType.DATE]: t(msg`Date`),
[FieldType.EMAIL]: t(msg`Email`),
};
};

View File

@ -1,5 +1,7 @@
import type { SubscriptionClaim } from '@prisma/client';
import { DEFAULT_MINIMUM_ENVELOPE_ITEM_COUNT } from '@documenso/ee/server-only/limits/constants';
export const generateDefaultSubscriptionClaim = (): Omit<
SubscriptionClaim,
'id' | 'organisation' | 'createdAt' | 'updatedAt' | 'originalSubscriptionClaimId'
@ -8,6 +10,7 @@ export const generateDefaultSubscriptionClaim = (): Omit<
name: '',
teamCount: 1,
memberCount: 1,
envelopeItemCount: DEFAULT_MINIMUM_ENVELOPE_ITEM_COUNT,
locked: false,
flags: {},
};

View File

@ -0,0 +1,20 @@
-- AlterTable
ALTER TABLE "SubscriptionClaim" ADD COLUMN "envelopeItemCount" INTEGER;
ALTER TABLE "OrganisationClaim" ADD COLUMN "envelopeItemCount" INTEGER;
-- Update ALL subscriptions to have 5 envelope items
UPDATE "SubscriptionClaim" SET "envelopeItemCount" = 5;
-- Override platform and enterprise claims to have 10 envelope items
UPDATE "SubscriptionClaim" SET "envelopeItemCount" = 10 WHERE "id" = 'platform';
UPDATE "SubscriptionClaim" SET "envelopeItemCount" = 10 WHERE "id" = 'enterprise';
-- Update ALL organisations to have 5 envelope items
UPDATE "OrganisationClaim" SET "envelopeItemCount" = 5;
-- Override platform and enterprise organisations to have 10 envelope items
UPDATE "OrganisationClaim" SET "envelopeItemCount" = 10 WHERE "originalSubscriptionClaimId" = 'platform';
UPDATE "OrganisationClaim" SET "envelopeItemCount" = 10 WHERE "originalSubscriptionClaimId" = 'enterprise';
ALTER TABLE "SubscriptionClaim" ALTER COLUMN "envelopeItemCount" SET NOT NULL;
ALTER TABLE "OrganisationClaim" ALTER COLUMN "envelopeItemCount" SET NOT NULL;

View File

@ -258,8 +258,9 @@ model SubscriptionClaim {
name String
locked Boolean @default(false)
teamCount Int
memberCount Int
teamCount Int
memberCount Int
envelopeItemCount Int
flags Json /// [ClaimFlags] @zod.custom.use(ZClaimFlagsSchema)
}
@ -273,8 +274,9 @@ model OrganisationClaim {
originalSubscriptionClaimId String?
organisation Organisation?
teamCount Int
memberCount Int
teamCount Int
memberCount Int
envelopeItemCount Int
flags Json /// [ClaimFlags] @zod.custom.use(ZClaimFlagsSchema)
}

View File

@ -11,6 +11,7 @@ module.exports = {
fontFamily: {
sans: ['var(--font-sans)', ...fontFamily.sans],
signature: ['var(--font-signature)'],
noto: ['var(--font-noto)'],
},
zIndex: {
9999: '9999',

View File

@ -10,7 +10,7 @@ export const createSubscriptionClaimRoute = adminProcedure
.input(ZCreateSubscriptionClaimRequestSchema)
.output(ZCreateSubscriptionClaimResponseSchema)
.mutation(async ({ input, ctx }) => {
const { name, teamCount, memberCount, flags } = input;
const { name, teamCount, memberCount, envelopeItemCount, flags } = input;
ctx.logger.info({
input,
@ -20,6 +20,7 @@ export const createSubscriptionClaimRoute = adminProcedure
data: {
name,
teamCount,
envelopeItemCount,
memberCount,
flags,
},

View File

@ -6,6 +6,7 @@ export const ZCreateSubscriptionClaimRequestSchema = z.object({
name: z.string().min(1),
teamCount: z.number().int().min(0),
memberCount: z.number().int().min(0),
envelopeItemCount: z.number().int().min(1),
flags: ZClaimFlagsSchema,
});

View File

@ -13,6 +13,7 @@ export const ZFindSubscriptionClaimsResponseSchema = ZFindResultResponse.extend(
name: true,
teamCount: true,
memberCount: true,
envelopeItemCount: true,
locked: true,
flags: true,
}).array(),

View File

@ -12,6 +12,7 @@ export const ZUpdateAdminOrganisationRequestSchema = z.object({
claims: ZCreateSubscriptionClaimRequestSchema.pick({
teamCount: true,
memberCount: true,
envelopeItemCount: true,
flags: true,
}).optional(),
customerId: z.string().optional(),

View File

@ -83,6 +83,7 @@ export const createDocumentTemporaryRoute = authenticatedProcedure
folderId,
envelopeItems: [
{
// If you ever allow more than 1 in this endpoint, make sure to use `maximumEnvelopeItemCount` to limit it.
documentDataId: documentData.id,
},
],

View File

@ -44,6 +44,7 @@ export const createDocumentRoute = authenticatedProcedure
folderId,
envelopeItems: [
{
// If you ever allow more than 1 in this endpoint, make sure to use `maximumEnvelopeItemCount` to limit it.
documentDataId,
},
],

View File

@ -23,7 +23,6 @@ export const createEnvelopeItemsRoute = authenticatedProcedure
},
});
// Todo: Envelopes - What to do about "normalizing"?
const { envelopeWhereInput } = await getEnvelopeWhereInput({
id: {
type: 'envelopeId',
@ -43,6 +42,15 @@ export const createEnvelopeItemsRoute = authenticatedProcedure
order: 'asc',
},
},
team: {
select: {
organisation: {
select: {
organisationClaim: true,
},
},
},
},
},
});
@ -58,7 +66,17 @@ export const createEnvelopeItemsRoute = authenticatedProcedure
});
}
// Todo: Envelopes - Limit amount of items that can be created.
const organisationClaim = envelope.team.organisation.organisationClaim;
const remainingEnvelopeItems =
organisationClaim.envelopeItemCount - envelope.envelopeItems.length - items.length;
if (remainingEnvelopeItems < 0) {
throw new AppError('ENVELOPE_ITEM_LIMIT_EXCEEDED', {
message: `You cannot upload more than ${organisationClaim.envelopeItemCount} envelope items`,
statusCode: 400,
});
}
const foundDocumentData = await prisma.documentData.findMany({
where: {
@ -93,7 +111,7 @@ export const createEnvelopeItemsRoute = authenticatedProcedure
envelope.envelopeItems[envelope.envelopeItems.length - 1]?.order ?? 1;
const result = await prisma.envelopeItem.createManyAndReturn({
data: items.map((item, index) => ({
data: items.map((item) => ({
id: prefixedId('envelope_item'),
envelopeId,
title: item.title,

View File

@ -33,8 +33,10 @@ export const createEnvelopeRoute = authenticatedProcedure
},
});
// Todo: Envelopes - Put the claims for number of items into this.
const { remaining } = await getServerLimits({ userId: user.id, teamId });
const { remaining, maximumEnvelopeItemCount } = await getServerLimits({
userId: user.id,
teamId,
});
if (remaining.documents <= 0) {
throw new AppError(AppErrorCode.LIMIT_EXCEEDED, {
@ -43,6 +45,13 @@ export const createEnvelopeRoute = authenticatedProcedure
});
}
if (items.length > maximumEnvelopeItemCount) {
throw new AppError('ENVELOPE_ITEM_LIMIT_EXCEEDED', {
message: `You cannot upload more than ${maximumEnvelopeItemCount} envelope items per envelope`,
statusCode: 400,
});
}
const envelope = await createEnvelope({
userId: user.id,
teamId,

View File

@ -52,13 +52,29 @@ export const deleteEnvelopeItemRoute = authenticatedProcedure
});
}
await prisma.envelopeItem.delete({
const deletedEnvelopeItem = await prisma.envelopeItem.delete({
where: {
id: envelopeItemId,
envelopeId: envelope.id,
},
select: {
documentData: {
select: {
id: true,
},
},
},
});
// Todo: Envelopes - Audit logs?
// Todo: Envelopes - Delete the document data as well?
// Todo: Envelopes [ASK] - Should we delete the document data?
await prisma.documentData.delete({
where: {
id: deletedEnvelopeItem.documentData.id,
envelopeItem: {
is: null,
},
},
});
// Todo: Envelope [AUDIT_LOGS]
});

View File

@ -0,0 +1,119 @@
import { EnvelopeType } from '@prisma/client';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { getEnvelopeWhereInput } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
import { prisma } from '@documenso/prisma';
import { maybeAuthenticatedProcedure } from '../trpc';
import {
ZGetEnvelopeItemsByTokenRequestSchema,
ZGetEnvelopeItemsByTokenResponseSchema,
} from './get-envelope-items-by-token.types';
// Not intended for V2 API usage.
// NOTE: THIS IS A PUBLIC PROCEDURE
export const getEnvelopeItemsByTokenRoute = maybeAuthenticatedProcedure
.input(ZGetEnvelopeItemsByTokenRequestSchema)
.output(ZGetEnvelopeItemsByTokenResponseSchema)
.query(async ({ input, ctx }) => {
const { teamId, user } = ctx;
const { envelopeId, access } = input;
ctx.logger.info({
input: {
envelopeId,
access,
},
});
if (access.type === 'user') {
if (!user || !teamId) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'User not found',
});
}
return await handleGetEnvelopeItemsByUser({ envelopeId, userId: user.id, teamId });
}
return await handleGetEnvelopeItemsByToken({ envelopeId, token: access.token });
});
const handleGetEnvelopeItemsByToken = async ({
envelopeId,
token,
}: {
envelopeId: string;
token: string;
}) => {
const envelope = await prisma.envelope.findFirst({
where: {
id: envelopeId,
type: EnvelopeType.DOCUMENT, // You cannot get template envelope items by token.
recipients: {
some: {
token,
},
},
},
include: {
envelopeItems: {
include: {
documentData: true,
},
},
},
});
if (!envelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Envelope could not be found',
});
}
return {
envelopeItems: envelope.envelopeItems,
};
};
const handleGetEnvelopeItemsByUser = async ({
envelopeId,
userId,
teamId,
}: {
envelopeId: string;
userId: number;
teamId: number;
}) => {
const { envelopeWhereInput } = await getEnvelopeWhereInput({
id: {
type: 'envelopeId',
id: envelopeId,
},
type: null,
userId,
teamId,
});
const envelope = await prisma.envelope.findUnique({
where: envelopeWhereInput,
include: {
envelopeItems: {
include: {
documentData: true,
},
},
},
});
if (!envelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Envelope could not be found',
});
}
return {
envelopeItems: envelope.envelopeItems,
};
};

View File

@ -0,0 +1,34 @@
import { z } from 'zod';
import DocumentDataSchema from '@documenso/prisma/generated/zod/modelSchema/DocumentDataSchema';
import EnvelopeItemSchema from '@documenso/prisma/generated/zod/modelSchema/EnvelopeItemSchema';
export const ZGetEnvelopeItemsByTokenRequestSchema = z.object({
envelopeId: z.string(),
access: z.discriminatedUnion('type', [
z.object({
type: z.literal('recipient'),
token: z.string(),
}),
z.object({
type: z.literal('user'),
}),
]),
});
export const ZGetEnvelopeItemsByTokenResponseSchema = z.object({
envelopeItems: EnvelopeItemSchema.pick({
id: true,
title: true,
order: true,
})
.extend({
documentData: DocumentDataSchema.pick({
type: true,
id: true,
data: true,
initialData: true,
}),
})
.array(),
});

View File

@ -0,0 +1,55 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { getEnvelopeWhereInput } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
import { prisma } from '@documenso/prisma';
import { authenticatedProcedure } from '../trpc';
import {
ZGetEnvelopeItemsRequestSchema,
ZGetEnvelopeItemsResponseSchema,
} from './get-envelope-items.types';
// Not intended for V2 API usage.
export const getEnvelopeItemsRoute = authenticatedProcedure
.input(ZGetEnvelopeItemsRequestSchema)
.output(ZGetEnvelopeItemsResponseSchema)
.query(async ({ input, ctx }) => {
const { teamId, user } = ctx;
const { envelopeId } = input;
ctx.logger.info({
input: {
envelopeId,
},
});
const { envelopeWhereInput } = await getEnvelopeWhereInput({
id: {
type: 'envelopeId',
id: envelopeId,
},
type: null,
userId: user.id,
teamId,
});
const envelope = await prisma.envelope.findUnique({
where: envelopeWhereInput,
include: {
envelopeItems: {
include: {
documentData: true,
},
},
},
});
if (!envelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Envelope could not be found',
});
}
return {
envelopeItems: envelope.envelopeItems,
};
});

View File

@ -0,0 +1,25 @@
import { z } from 'zod';
import DocumentDataSchema from '@documenso/prisma/generated/zod/modelSchema/DocumentDataSchema';
import EnvelopeItemSchema from '@documenso/prisma/generated/zod/modelSchema/EnvelopeItemSchema';
export const ZGetEnvelopeItemsRequestSchema = z.object({
envelopeId: z.string(),
});
export const ZGetEnvelopeItemsResponseSchema = z.object({
envelopeItems: EnvelopeItemSchema.pick({
id: true,
title: true,
order: true,
})
.extend({
documentData: DocumentDataSchema.pick({
type: true,
id: true,
data: true,
initialData: true,
}),
})
.array(),
});

View File

@ -10,6 +10,8 @@ import { deleteEnvelopeItemRoute } from './delete-envelope-item';
import { distributeEnvelopeRoute } from './distribute-envelope';
import { duplicateEnvelopeRoute } from './duplicate-envelope';
import { getEnvelopeRoute } from './get-envelope';
import { getEnvelopeItemsRoute } from './get-envelope-items';
import { getEnvelopeItemsByTokenRoute } from './get-envelope-items-by-token';
import { redistributeEnvelopeRoute } from './redistribute-envelope';
import { setEnvelopeFieldsRoute } from './set-envelope-fields';
import { setEnvelopeRecipientsRoute } from './set-envelope-recipients';
@ -28,6 +30,8 @@ export const envelopeRouter = router({
// share: shareEnvelopeRoute,
item: {
getMany: getEnvelopeItemsRoute,
getManyByToken: getEnvelopeItemsByTokenRoute,
createMany: createEnvelopeItemsRoute,
updateMany: updateEnvelopeItemsRoute,
delete: deleteEnvelopeItemRoute,

View File

@ -1,26 +1,12 @@
import { DocumentStatus, FieldType, RecipientRole, SigningStatus } from '@prisma/client';
import { DateTime } from 'luxon';
import { P, match } from 'ts-pattern';
import { z } from 'zod';
import { match } from 'ts-pattern';
import { validateCheckboxField } from '@documenso/lib/advanced-fields-validation/validate-checkbox';
import { validateDropdownField } from '@documenso/lib/advanced-fields-validation/validate-dropdown';
import { validateNumberField } from '@documenso/lib/advanced-fields-validation/validate-number';
import { validateRadioField } from '@documenso/lib/advanced-fields-validation/validate-radio';
import { validateTextField } from '@documenso/lib/advanced-fields-validation/validate-text';
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
import { isBase64Image } from '@documenso/lib/constants/signatures';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { validateFieldAuth } from '@documenso/lib/server-only/document/validate-field-auth';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import {
ZCheckboxFieldMeta,
ZDropdownFieldMeta,
ZNumberFieldMeta,
ZRadioFieldMeta,
ZTextFieldMeta,
} from '@documenso/lib/types/field-meta';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { extractFieldInsertionValues } from '@documenso/lib/utils/envelope-signing';
import { prisma } from '@documenso/prisma';
import { procedure } from '../trpc';
@ -53,21 +39,21 @@ export const signEnvelopeFieldRoute = procedure
throw new AppError(AppErrorCode.NOT_FOUND);
}
const field = await prisma.field.findFirstOrThrow({
const field = await prisma.field.findFirst({
where: {
id: fieldId,
recipient: {
...(recipient.role !== RecipientRole.ASSISTANT
...(recipient.role === RecipientRole.ASSISTANT
? {
id: recipient.id,
}
: {
signingStatus: {
not: SigningStatus.SIGNED,
},
signingOrder: {
gte: recipient.signingOrder ?? 0,
},
}
: {
id: recipient.id,
}),
},
},
@ -82,21 +68,31 @@ export const signEnvelopeFieldRoute = procedure
},
});
const { envelope } = field;
const { documentMeta } = envelope;
if (!envelope || !recipient) {
if (!field) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: `Document not found for field ${field.id}`,
message: `Field ${fieldId} not found`,
});
}
const { envelope } = field;
const { documentMeta } = envelope;
if (envelope.internalVersion !== 2) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: `Envelope ${envelope.id} is not a version 2 envelope`,
});
}
if (
field.type === FieldType.SIGNATURE &&
recipient.id !== field.recipientId &&
recipient.role === RecipientRole.ASSISTANT
) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: `Assistant recipients cannot sign signature fields`,
});
}
if (fieldValue.type !== field.type) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Selected values do not match the field values',
@ -124,7 +120,6 @@ export const signEnvelopeFieldRoute = procedure
});
}
// Todo: Envelopes - Need to auto insert read only fields during sealing.
if (field.fieldMeta?.readOnly) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: `Field ${fieldId} is read only`,
@ -136,233 +131,7 @@ export const signEnvelopeFieldRoute = procedure
throw new Error(`Field ${fieldId} has no recipientId`);
}
let signatureImageAsBase64: string | null = null;
let typedSignature: string | null = null;
const insertionValues: { customText: string; inserted: boolean } = match(fieldValue)
.with({ type: FieldType.EMAIL }, (fieldValue) => {
const parsedEmailValue = z.string().email().nullable().safeParse(fieldValue.value);
if (!parsedEmailValue.success) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: 'Invalid email',
});
}
if (parsedEmailValue.data === null) {
return {
customText: '',
inserted: false,
};
}
return {
customText: parsedEmailValue.data,
inserted: true,
};
})
.with({ type: P.union(FieldType.NAME, FieldType.INITIALS) }, (fieldValue) => {
const parsedGenericStringValue = z.string().min(1).nullable().safeParse(fieldValue.value);
if (!parsedGenericStringValue.success) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: 'Value is required',
});
}
if (parsedGenericStringValue.data === null) {
return {
customText: '',
inserted: false,
};
}
return {
customText: parsedGenericStringValue.data,
inserted: true,
};
})
.with({ type: FieldType.DATE }, (fieldValue) => {
if (!fieldValue.value) {
return {
customText: '',
inserted: false,
};
}
return {
customText: DateTime.now()
.setZone(documentMeta.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE)
.toFormat(documentMeta.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT),
inserted: true,
};
})
.with({ type: FieldType.NUMBER }, (fieldValue) => {
if (!fieldValue.value) {
return {
customText: '',
inserted: false,
};
}
const numberFieldParsedMeta = ZNumberFieldMeta.parse(field.fieldMeta);
const errors = validateNumberField(
fieldValue.value.toString(),
numberFieldParsedMeta,
true,
);
// Todo
if (errors.length > 0) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: 'Invalid number',
});
}
return {
customText: fieldValue.value.toString(),
inserted: true,
};
})
.with({ type: FieldType.TEXT }, (fieldValue) => {
if (fieldValue.value === null) {
return {
customText: '',
inserted: false,
};
}
const parsedTextFieldMeta = ZTextFieldMeta.parse(field.fieldMeta);
const errors = validateTextField(fieldValue.value, parsedTextFieldMeta, true);
// Todo
if (errors.length > 0) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: 'Invalid email',
});
}
return {
customText: fieldValue.value,
inserted: true,
};
})
.with({ type: FieldType.RADIO }, (fieldValue) => {
if (fieldValue.value === null) {
return {
customText: '',
inserted: false,
};
}
const parsedRadioFieldParsedMeta = ZRadioFieldMeta.parse(field.fieldMeta);
const errors = validateRadioField(fieldValue.value, parsedRadioFieldParsedMeta, true);
if (errors.length > 0) {
throw new Error(errors.join(', '));
}
// Todo
if (errors.length > 0) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: 'Invalid radio value',
});
}
return {
customText: fieldValue.value,
inserted: true,
};
})
.with({ type: FieldType.CHECKBOX }, (fieldValue) => {
if (fieldValue.value === null) {
return {
customText: '',
inserted: false,
};
}
// Todo: Envelopes - This won't work.
const parsedCheckboxFieldParsedMeta = ZCheckboxFieldMeta.parse(field.fieldMeta);
const checkboxFieldValues = parsedCheckboxFieldParsedMeta.values || [];
const { value } = fieldValue;
const selectedValues = checkboxFieldValues.filter(({ id }) => value.some((v) => v === id));
if (selectedValues.length !== value.length) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Selected values do not match the checkbox field values',
});
}
const errors = validateCheckboxField(
selectedValues.map(({ value }) => value),
parsedCheckboxFieldParsedMeta,
true,
);
if (errors.length > 0) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: 'Invalid checkbox value:' + errors.join(', '),
});
}
return {
customText: JSON.stringify(fieldValue.value),
inserted: true,
};
})
.with({ type: FieldType.DROPDOWN }, (fieldValue) => {
if (fieldValue.value === null) {
return {
customText: '',
inserted: false,
};
}
const parsedDropdownFieldMeta = ZDropdownFieldMeta.parse(field.fieldMeta);
const errors = validateDropdownField(fieldValue.value, parsedDropdownFieldMeta, true);
// Todo
if (errors.length > 0) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: 'Invalid dropdown value',
});
}
return {
customText: fieldValue.value,
inserted: true,
};
})
.with({ type: FieldType.SIGNATURE }, (fieldValue) => {
const { value, isBase64 } = fieldValue;
if (!value) {
return {
customText: '',
inserted: false,
};
}
signatureImageAsBase64 = isBase64 ? value : null;
typedSignature = !isBase64 ? value : null;
if (documentMeta.typedSignatureEnabled === false && typedSignature) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: 'Typed signatures are not allowed. Please draw your signature',
});
}
return {
customText: '',
inserted: true,
};
})
.exhaustive();
const insertionValues = extractFieldInsertionValues({ fieldValue, field, documentMeta });
const derivedRecipientActionAuth = await validateFieldAuth({
documentAuthOptions: envelope.authOptions,
@ -374,6 +143,24 @@ export const signEnvelopeFieldRoute = procedure
const assistant = recipient.role === RecipientRole.ASSISTANT ? recipient : undefined;
let signatureImageAsBase64 = null;
let typedSignature = null;
if (field.type === FieldType.SIGNATURE) {
if (fieldValue.type !== FieldType.SIGNATURE) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: `Field ${fieldId} is not a signature field`,
});
}
if (fieldValue.value) {
const isBase64 = isBase64Image(fieldValue.value);
signatureImageAsBase64 = isBase64 ? fieldValue.value : null;
typedSignature = !isBase64 ? fieldValue.value : null;
}
}
return await prisma.$transaction(async (tx) => {
const updatedField = await tx.field.update({
where: {

View File

@ -8,11 +8,11 @@ import SignatureSchema from '@documenso/prisma/generated/zod/modelSchema/Signatu
export const ZSignEnvelopeFieldValue = z.discriminatedUnion('type', [
z.object({
type: z.literal(FieldType.CHECKBOX),
value: z.array(z.number()),
value: z.array(z.number()).describe('The indices of the selected options'),
}),
z.object({
type: z.literal(FieldType.RADIO),
value: z.string().nullable(),
value: z.number().nullable().describe('The index of the selected option'),
}),
z.object({
type: z.literal(FieldType.NUMBER),
@ -45,7 +45,6 @@ export const ZSignEnvelopeFieldValue = z.discriminatedUnion('type', [
z.object({
type: z.literal(FieldType.SIGNATURE),
value: z.string().nullable(),
isBase64: z.boolean(),
}),
]);

View File

@ -90,8 +90,7 @@ export const updateEnvelopeItemsRoute = authenticatedProcedure
),
);
// Todo: Envelope - Audit logs?
// Todo: Envelopes - Delete the document data as well?
// Todo: Envelope [AUDIT_LOGS]
return {
updatedEnvelopeItems,

View File

@ -8,7 +8,7 @@ export const ZUpdateEnvelopeItemsRequestSchema = z.object({
envelopeId: z.string(),
data: z
.object({
envelopeItemId: z.string(),
envelopeItemId: z.string().describe('The ID of the envelope item to update.'),
order: z.number().int().min(1).optional(),
title: ZDocumentTitleSchema.optional(),
})

View File

@ -0,0 +1,135 @@
import { useCallback, useEffect, useState } from 'react';
import type { Field } from '@prisma/client';
import { TooltipArrow } from '@radix-ui/react-tooltip';
import type { VariantProps } from 'class-variance-authority';
import { cva } from 'class-variance-authority';
import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { cn } from '../../lib/utils';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../../primitives/tooltip';
const tooltipVariants = cva('font-semibold', {
variants: {
color: {
default: 'border-2 fill-white',
warning: 'border-0 bg-orange-300 fill-orange-300 text-orange-900',
},
},
defaultVariants: {
color: 'default',
},
});
interface EnvelopeFieldToolTipProps extends VariantProps<typeof tooltipVariants> {
children: React.ReactNode;
className?: string;
field: Pick<
Field,
'id' | 'inserted' | 'fieldMeta' | 'positionX' | 'positionY' | 'width' | 'height' | 'page'
>;
}
/**
* Renders a tooltip for a given field.
*/
export function EnvelopeFieldToolTip({
children,
color,
className = '',
field,
}: EnvelopeFieldToolTipProps) {
const [coords, setCoords] = useState({
x: 0,
y: 0,
height: 0,
width: 0,
});
const calculateCoords = useCallback(() => {
const $page = document.querySelector<HTMLElement>(
`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${field.page}"]`,
);
if (!$page) {
return;
}
const { height, width } = getBoundingClientRect($page);
const fieldX = (Number(field.positionX) / 100) * width;
const fieldY = (Number(field.positionY) / 100) * height;
const fieldHeight = (Number(field.height) / 100) * height;
const fieldWidth = (Number(field.width) / 100) * width;
setCoords({
x: fieldX,
y: fieldY,
height: fieldHeight,
width: fieldWidth,
});
}, [field.height, field.page, field.positionX, field.positionY, field.width]);
useEffect(() => {
calculateCoords();
}, [calculateCoords]);
useEffect(() => {
const onResize = () => {
calculateCoords();
};
window.addEventListener('resize', onResize);
return () => {
window.removeEventListener('resize', onResize);
};
}, [calculateCoords]);
useEffect(() => {
const $page = document.querySelector<HTMLElement>(
`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${field.page}"]`,
);
if (!$page) {
return;
}
const observer = new ResizeObserver(() => {
calculateCoords();
});
observer.observe($page);
return () => {
observer.disconnect();
};
}, [calculateCoords, field.page]);
return (
<div
id="field-tooltip"
className={cn('pointer-events-none absolute')}
style={{
top: `${coords.y}px`,
left: `${coords.x}px`,
height: `${coords.height}px`,
width: `${coords.width}px`,
}}
>
<TooltipProvider>
<Tooltip delayDuration={0} open={!field.inserted || !field.fieldMeta}>
<TooltipTrigger className="absolute inset-0 w-full"></TooltipTrigger>
<TooltipContent className={tooltipVariants({ color, className })} sideOffset={2}>
{children}
<TooltipArrow />
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
);
}

View File

@ -91,11 +91,11 @@ export const PdfViewerKonva = ({
}, []);
return (
<div ref={$el} className={cn('w-[800px] overflow-hidden', className)} {...props}>
<div ref={$el} className={cn('w-full max-w-[800px]', className)} {...props}>
{envelopeItemFile && Konva ? (
<PDFDocument
file={envelopeItemFile}
className={cn('w-full overflow-hidden rounded', {
className={cn('w-full rounded', {
'h-[80vh] max-h-[60rem]': numPages === 0,
})}
onLoadSuccess={(d) => onDocumentLoaded(d)}
@ -138,7 +138,7 @@ export const PdfViewerKonva = ({
.fill(null)
.map((_, i) => (
<div key={i} className="last:-mb-2">
<div className="border-border overflow-hidden rounded border will-change-transform">
<div className="border-border rounded border will-change-transform">
<PDFPage
pageNumber={i + 1}
width={width}

View File

@ -9,6 +9,7 @@ export type RecipientColorStyles = {
base: string;
baseRing: string;
baseRingHover: string;
fieldButton: string;
fieldItem: string;
fieldItemInitials: string;
comboxBoxTrigger: string;
@ -23,6 +24,7 @@ export const RECIPIENT_COLOR_STYLES = {
base: 'ring-neutral-400',
baseRing: 'rgba(176, 176, 176, 1)',
baseRingHover: 'rgba(176, 176, 176, 1)',
fieldButton: 'border-neutral-400 hover:border-neutral-400',
fieldItem: 'group/field-item rounded-[2px]',
fieldItemInitials: '',
comboxBoxTrigger:
@ -34,6 +36,7 @@ export const RECIPIENT_COLOR_STYLES = {
base: 'ring-recipient-green hover:bg-recipient-green/30',
baseRing: 'rgba(122, 195, 85, 1)',
baseRingHover: 'rgba(122, 195, 85, 0.3)',
fieldButton: 'hover:border-recipient-green hover:bg-recipient-green/30 ',
fieldItem: 'group/field-item rounded-[2px]',
fieldItemInitials: 'group-hover/field-item:bg-recipient-green',
comboxBoxTrigger:
@ -45,6 +48,7 @@ export const RECIPIENT_COLOR_STYLES = {
base: 'ring-recipient-blue hover:bg-recipient-blue/30',
baseRing: 'rgba(56, 123, 199, 1)',
baseRingHover: 'rgba(56, 123, 199, 0.3)',
fieldButton: 'hover:border-recipient-blue hover:bg-recipient-blue/30',
fieldItem: 'group/field-item rounded-[2px]',
fieldItemInitials: 'group-hover/field-item:bg-recipient-blue',
comboxBoxTrigger:
@ -56,6 +60,7 @@ export const RECIPIENT_COLOR_STYLES = {
base: 'ring-recipient-purple hover:bg-recipient-purple/30',
baseRing: 'rgba(151, 71, 255, 1)',
baseRingHover: 'rgba(151, 71, 255, 0.3)',
fieldButton: 'hover:border-recipient-purple hover:bg-recipient-purple/30',
fieldItem: 'group/field-item rounded-[2px]',
fieldItemInitials: 'group-hover/field-item:bg-recipient-purple',
comboxBoxTrigger:
@ -67,6 +72,7 @@ export const RECIPIENT_COLOR_STYLES = {
base: 'ring-recipient-orange hover:bg-recipient-orange/30',
baseRing: 'rgba(246, 159, 30, 1)',
baseRingHover: 'rgba(246, 159, 30, 0.3)',
fieldButton: 'hover:border-recipient-orange hover:bg-recipient-orange/30',
fieldItem: 'group/field-item rounded-[2px]',
fieldItemInitials: 'group-hover/field-item:bg-recipient-orange',
comboxBoxTrigger:
@ -78,6 +84,7 @@ export const RECIPIENT_COLOR_STYLES = {
base: 'ring-recipient-yellow hover:bg-recipient-yellow/30',
baseRing: 'rgba(219, 186, 0, 1)',
baseRingHover: 'rgba(219, 186, 0, 0.3)',
fieldButton: 'hover:border-recipient-yellow hover:bg-recipient-yellow/30',
fieldItem: 'group/field-item rounded-[2px]',
fieldItemInitials: 'group-hover/field-item:bg-recipient-yellow',
comboxBoxTrigger:
@ -89,6 +96,7 @@ export const RECIPIENT_COLOR_STYLES = {
base: 'ring-recipient-pink hover:bg-recipient-pink/30',
baseRing: 'rgba(217, 74, 186, 1)',
baseRingHover: 'rgba(217, 74, 186, 0.3)',
fieldButton: 'hover:border-recipient-pink hover:bg-recipient-pink/30',
fieldItem: 'group/field-item rounded-[2px]',
fieldItemInitials: 'group-hover/field-item:bg-recipient-pink',
comboxBoxTrigger:

View File

@ -25,14 +25,25 @@ Command.displayName = CommandPrimitive.displayName;
type CommandDialogProps = DialogProps & {
commandProps?: React.ComponentPropsWithoutRef<typeof CommandPrimitive>;
position?: 'start' | 'end' | 'center';
dialogContentClassName?: string;
};
const CommandDialog = ({ children, commandProps, ...props }: CommandDialogProps) => {
const CommandDialog = ({
children,
commandProps,
position = 'center',
dialogContentClassName,
...props
}: CommandDialogProps) => {
return (
<Dialog {...props}>
<DialogContent
className="w-11/12 items-center overflow-hidden rounded-lg p-0 shadow-2xl lg:mt-0"
position="center"
className={cn(
'w-11/12 items-center overflow-hidden rounded-lg p-0 shadow-2xl lg:mt-0',
dialogContentClassName,
)}
position={position}
overlayClassName="bg-background/60"
>
<Command

View File

@ -4,6 +4,7 @@ import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { motion } from 'framer-motion';
import { AlertTriangle, Plus } from 'lucide-react';
import type { FileRejection } from 'react-dropzone';
import { useDropzone } from 'react-dropzone';
import { Link } from 'react-router';
@ -31,8 +32,9 @@ export type DocumentDropzoneProps = {
disabledHeading?: MessageDescriptor;
disabledMessage?: MessageDescriptor;
onDrop?: (_file: File[]) => void | Promise<void>;
onDropRejected?: () => void | Promise<void>;
onDropRejected?: (fileRejections: FileRejection[]) => void;
type?: 'document' | 'template';
maxFiles?: number;
[key: string]: unknown;
};
@ -45,6 +47,7 @@ export const DocumentDropzone = ({
disabledHeading,
disabledMessage = msg`You cannot upload documents at this time.`,
type = 'document',
maxFiles,
...props
}: DocumentDropzoneProps) => {
const { _ } = useLingui();
@ -62,11 +65,8 @@ export const DocumentDropzone = ({
void onDrop(acceptedFiles);
}
},
onDropRejected: () => {
if (onDropRejected) {
void onDropRejected();
}
},
onDropRejected,
maxFiles,
maxSize: megabytesToBytes(APP_DOCUMENT_UPLOAD_SIZE_LIMIT),
});

View File

@ -122,6 +122,7 @@ const getDefaultState = (fieldType: FieldType): FieldMeta => {
values: [],
required: false,
readOnly: false,
direction: 'vertical',
};
case FieldType.CHECKBOX:
return {

View File

@ -31,4 +31,4 @@ export const checkboxValidationSigns = [
label: 'Select at most',
value: '<=',
},
];
] as const;

View File

@ -72,7 +72,13 @@ export const RadioFieldAdvancedSettings = ({
setReadOnly(readOnly);
setRequired(required);
const errors = validateRadioField(String(value), { readOnly, required, values, type: 'radio' });
const errors = validateRadioField(String(value), {
readOnly,
required,
values,
type: 'radio',
direction: 'vertical',
});
handleErrors(errors);
handleFieldChange(field, value);
@ -97,7 +103,13 @@ export const RadioFieldAdvancedSettings = ({
}, [fieldState.values]);
useEffect(() => {
const errors = validateRadioField(undefined, { readOnly, required, values, type: 'radio' });
const errors = validateRadioField(undefined, {
readOnly,
required,
values,
type: 'radio',
direction: 'vertical',
});
handleErrors(errors);
}, [values]);

View File

@ -3,6 +3,7 @@ import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { Upload } from 'lucide-react';
import type { DropEvent, FileRejection } from 'react-dropzone';
import { useDropzone } from 'react-dropzone';
import { Link } from 'react-router';
@ -21,8 +22,9 @@ export type DocumentDropzoneProps = {
loading?: boolean;
disabledMessage?: MessageDescriptor;
onDrop?: (_files: File[]) => void | Promise<void>;
onDropRejected?: () => void | Promise<void>;
onDropRejected?: (fileRejections: FileRejection[], event: DropEvent) => void;
type?: 'document' | 'template' | 'envelope';
maxFiles?: number;
[key: string]: unknown;
};
@ -34,6 +36,7 @@ export const DocumentDropzone = ({
disabled,
disabledMessage = msg`You cannot upload documents at this time.`,
type = 'document',
maxFiles,
...props
}: DocumentDropzoneProps) => {
const { _ } = useLingui();
@ -50,17 +53,13 @@ export const DocumentDropzone = ({
},
multiple: type === 'envelope',
disabled,
maxFiles: 10, // Todo: Envelopes - Use claims. And also update other places where this is used.
maxFiles,
onDrop: (acceptedFiles) => {
if (acceptedFiles.length > 0 && onDrop) {
void onDrop(acceptedFiles);
}
},
onDropRejected: () => {
if (onDropRejected) {
void onDropRejected();
}
},
onDropRejected,
maxSize: megabytesToBytes(APP_DOCUMENT_UPLOAD_SIZE_LIMIT),
});