Merge branch 'main' into feat/auto-placing-fields

This commit is contained in:
Catalin Pit
2025-10-29 14:29:04 +02:00
committed by GitHub
216 changed files with 10779 additions and 3644 deletions

View File

@ -427,6 +427,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
globalAccessAuth: body.authOptions?.globalAccessAuth,
globalActionAuth: body.authOptions?.globalActionAuth,
},
attachments: body.attachments,
meta: {
subject: body.meta.subject,
message: body.meta.message,
@ -497,6 +498,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
publicDescription,
type,
meta,
attachments,
} = body;
try {
@ -568,6 +570,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
publicDescription,
},
meta,
attachments,
requestMetadata: metadata,
});
@ -792,6 +795,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
...body.meta,
title: body.title,
},
attachments: body.attachments,
requestMetadata: metadata,
});

View File

@ -22,6 +22,7 @@ import {
ZRecipientActionAuthTypesSchema,
} from '@documenso/lib/types/document-auth';
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
import { ZEnvelopeAttachmentTypeSchema } from '@documenso/lib/types/envelope-attachment';
import { ZFieldMetaPrefillFieldsSchema, ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
extendZodWithOpenApi(z);
@ -197,6 +198,15 @@ export const ZCreateDocumentMutationSchema = z.object({
description: 'The globalActionAuth property is only available for Enterprise accounts.',
}),
formValues: z.record(z.string(), z.union([z.string(), z.boolean(), z.number()])).optional(),
attachments: z
.array(
z.object({
label: z.string().min(1, 'Label is required'),
data: z.string().url('Must be a valid URL'),
type: ZEnvelopeAttachmentTypeSchema.optional().default('link'),
}),
)
.optional(),
});
export type TCreateDocumentMutationSchema = z.infer<typeof ZCreateDocumentMutationSchema>;
@ -262,6 +272,15 @@ export const ZCreateDocumentFromTemplateMutationSchema = z.object({
})
.optional(),
formValues: z.record(z.string(), z.union([z.string(), z.boolean(), z.number()])).optional(),
attachments: z
.array(
z.object({
label: z.string().min(1, 'Label is required'),
data: z.string().url('Must be a valid URL'),
type: ZEnvelopeAttachmentTypeSchema.optional().default('link'),
}),
)
.optional(),
});
export type TCreateDocumentFromTemplateMutationSchema = z.infer<

View File

@ -68,15 +68,29 @@ test('[ADMIN]: promote member to owner', async ({ page }) => {
// Test promoting a MEMBER to owner
const memberRow = page.getByRole('row', { name: memberUser.email });
// Find and click the "Promote to owner" button for the member
const promoteButton = memberRow.getByRole('button', { name: 'Promote to owner' });
await expect(promoteButton).toBeVisible();
await expect(promoteButton).not.toBeDisabled();
// Find and click the "Update role" button for the member
const updateRoleButton = memberRow.getByRole('button', {
name: 'Update role',
});
await expect(updateRoleButton).toBeVisible();
await expect(updateRoleButton).not.toBeDisabled();
await promoteButton.click();
await updateRoleButton.click();
// Verify success toast appears
await expect(page.getByText('Member promoted to owner successfully').first()).toBeVisible();
// Wait for dialog to open and select Owner role
await expect(page.getByRole('dialog')).toBeVisible();
// Find and click the select trigger - it's a button with role="combobox"
await page.getByRole('dialog').locator('button[role="combobox"]').click();
// Select "Owner" from the dropdown options
await page.getByRole('option', { name: 'Owner' }).click();
// Click Update button
await page.getByRole('dialog').getByRole('button', { name: 'Update' }).click();
// Wait for dialog to close (indicates success)
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10_000 });
// Reload the page to see the changes
await page.reload();
@ -89,12 +103,18 @@ test('[ADMIN]: promote member to owner', async ({ page }) => {
const previousOwnerRow = page.getByRole('row', { name: ownerUser.email });
await expect(previousOwnerRow.getByRole('status').filter({ hasText: 'Owner' })).not.toBeVisible();
// Verify that the promote button is now disabled for the new owner
const newOwnerPromoteButton = newOwnerRow.getByRole('button', { name: 'Promote to owner' });
await expect(newOwnerPromoteButton).toBeDisabled();
// Verify that the Update role button exists for the new owner and shows Owner as current role
const newOwnerUpdateButton = newOwnerRow.getByRole('button', {
name: 'Update role',
});
await expect(newOwnerUpdateButton).toBeVisible();
// Test that we can't promote the current owner (button should be disabled)
await expect(newOwnerPromoteButton).toHaveAttribute('disabled');
// Verify clicking it shows the dialog with Owner already selected
await newOwnerUpdateButton.click();
await expect(page.getByRole('dialog')).toBeVisible();
// Close the dialog without making changes
await page.getByRole('button', { name: 'Cancel' }).click();
});
test('[ADMIN]: promote manager to owner', async ({ page }) => {
@ -130,10 +150,26 @@ test('[ADMIN]: promote manager to owner', async ({ page }) => {
// Promote the manager to owner
const managerRow = page.getByRole('row', { name: managerUser.email });
const promoteButton = managerRow.getByRole('button', { name: 'Promote to owner' });
const updateRoleButton = managerRow.getByRole('button', {
name: 'Update role',
});
await promoteButton.click();
await expect(page.getByText('Member promoted to owner successfully').first()).toBeVisible();
await updateRoleButton.click();
// Wait for dialog to open and select Owner role
await expect(page.getByRole('dialog')).toBeVisible();
// Find and click the select trigger - it's a button with role="combobox"
await page.getByRole('dialog').locator('button[role="combobox"]').click();
// Select "Owner" from the dropdown options
await page.getByRole('option', { name: 'Owner' }).click();
// Click Update button
await page.getByRole('dialog').getByRole('button', { name: 'Update' }).click();
// Wait for dialog to close (indicates success)
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10_000 });
// Reload and verify the change
await page.reload();
@ -173,14 +209,27 @@ test('[ADMIN]: promote admin member to owner', async ({ page }) => {
// Promote the admin member to owner
const adminMemberRow = page.getByRole('row', { name: adminMemberUser.email });
const promoteButton = adminMemberRow.getByRole('button', { name: 'Promote to owner' });
await promoteButton.click();
await expect(page.getByText('Member promoted to owner successfully').first()).toBeVisible({
timeout: 10_000,
const updateRoleButton = adminMemberRow.getByRole('button', {
name: 'Update role',
});
await updateRoleButton.click();
// Wait for dialog to open and select Owner role
await expect(page.getByRole('dialog')).toBeVisible();
// Find and click the select trigger - it's a button with role="combobox"
await page.getByRole('dialog').locator('button[role="combobox"]').click();
// Select "Owner" from the dropdown options
await page.getByRole('option', { name: 'Owner' }).click();
// Click Update button
await page.getByRole('dialog').getByRole('button', { name: 'Update' }).click();
// Wait for dialog to close (indicates success)
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10_000 });
// Reload and verify the change
await page.reload();
await expect(adminMemberRow.getByRole('status').filter({ hasText: 'Owner' })).toBeVisible();
@ -249,11 +298,25 @@ test('[ADMIN]: verify role hierarchy after promotion', async ({ page }) => {
await expect(memberRow.getByRole('status').filter({ hasText: 'Owner' })).not.toBeVisible();
// Promote member to owner
const promoteButton = memberRow.getByRole('button', { name: 'Promote to owner' });
await promoteButton.click();
await expect(page.getByText('Member promoted to owner successfully').first()).toBeVisible({
timeout: 10_000,
const updateRoleButton = memberRow.getByRole('button', {
name: 'Update role',
});
await updateRoleButton.click();
// Wait for dialog to open and select Owner role
await expect(page.getByRole('dialog')).toBeVisible();
// Find and click the select trigger - it's a button with role="combobox"
await page.getByRole('dialog').locator('button[role="combobox"]').click();
// Select "Owner" from the dropdown options
await page.getByRole('option', { name: 'Owner' }).click();
// Click Update button
await page.getByRole('dialog').getByRole('button', { name: 'Update' }).click();
// Wait for dialog to close (indicates success)
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10_000 });
// Reload page to see updated state
await page.reload();
@ -262,9 +325,11 @@ test('[ADMIN]: verify role hierarchy after promotion', async ({ page }) => {
memberRow = page.getByRole('row', { name: memberUser.email });
await expect(memberRow.getByRole('status').filter({ hasText: 'Owner' })).toBeVisible();
// Verify the promote button is now disabled for the new owner
const newOwnerPromoteButton = memberRow.getByRole('button', { name: 'Promote to owner' });
await expect(newOwnerPromoteButton).toBeDisabled();
// Verify the Update role button exists and shows Owner as current role
const newOwnerUpdateButton = memberRow.getByRole('button', {
name: 'Update role',
});
await expect(newOwnerUpdateButton).toBeVisible();
// Sign in as the newly promoted user to verify they have owner permissions
await apiSignin({
@ -336,28 +401,56 @@ test('[ADMIN]: multiple promotions in sequence', async ({ page }) => {
// First promotion: Member 1 becomes owner
let member1Row = page.getByRole('row', { name: member1User.email });
let promoteButton1 = member1Row.getByRole('button', { name: 'Promote to owner' });
await promoteButton1.click();
await expect(page.getByText('Member promoted to owner successfully').first()).toBeVisible({
timeout: 10_000,
let updateRoleButton1 = member1Row.getByRole('button', {
name: 'Update role',
});
await updateRoleButton1.click();
// Wait for dialog to open and select Owner role
await expect(page.getByRole('dialog')).toBeVisible();
// Find and click the select trigger - it's a button with role="combobox"
await page.getByRole('dialog').locator('button[role="combobox"]').click();
// Select "Owner" from the dropdown options
await page.getByRole('option', { name: 'Owner' }).click();
// Click Update button
await page.getByRole('dialog').getByRole('button', { name: 'Update' }).click();
// Wait for dialog to close (indicates success)
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10_000 });
await page.reload();
// Verify Member 1 is now owner and button is disabled
// Verify Member 1 is now owner
member1Row = page.getByRole('row', { name: member1User.email });
await expect(member1Row.getByRole('status').filter({ hasText: 'Owner' })).toBeVisible();
promoteButton1 = member1Row.getByRole('button', { name: 'Promote to owner' });
await expect(promoteButton1).toBeDisabled();
updateRoleButton1 = member1Row.getByRole('button', { name: 'Update role' });
await expect(updateRoleButton1).toBeVisible();
// Second promotion: Member 2 becomes the new owner
const member2Row = page.getByRole('row', { name: member2User.email });
const promoteButton2 = member2Row.getByRole('button', { name: 'Promote to owner' });
await expect(promoteButton2).not.toBeDisabled();
await promoteButton2.click();
await expect(page.getByText('Member promoted to owner successfully').first()).toBeVisible({
timeout: 10_000,
const updateRoleButton2 = member2Row.getByRole('button', {
name: 'Update role',
});
await expect(updateRoleButton2).toBeVisible();
await updateRoleButton2.click();
// Wait for dialog to open and select Owner role
await expect(page.getByRole('dialog')).toBeVisible();
// Find and click the select trigger - it's a button with role="combobox"
await page.getByRole('dialog').locator('button[role="combobox"]').click();
// Select "Owner" from the dropdown options
await page.getByRole('option', { name: 'Owner' }).click();
// Click Update button
await page.getByRole('dialog').getByRole('button', { name: 'Update' }).click();
// Wait for dialog to close (indicates success)
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10_000 });
await page.reload();
@ -365,9 +458,11 @@ test('[ADMIN]: multiple promotions in sequence', async ({ page }) => {
await expect(member2Row.getByRole('status').filter({ hasText: 'Owner' })).toBeVisible();
await expect(member1Row.getByRole('status').filter({ hasText: 'Owner' })).not.toBeVisible();
// Verify Member 1's promote button is now enabled again
const newPromoteButton1 = member1Row.getByRole('button', { name: 'Promote to owner' });
await expect(newPromoteButton1).not.toBeDisabled();
// Verify Member 1's Update role button is still visible
const newUpdateButton1 = member1Row.getByRole('button', {
name: 'Update role',
});
await expect(newUpdateButton1).toBeVisible();
});
test('[ADMIN]: verify organisation access after ownership change', async ({ page }) => {
@ -402,11 +497,25 @@ test('[ADMIN]: verify organisation access after ownership change', async ({ page
});
const memberRow = page.getByRole('row', { name: memberUser.email });
const promoteButton = memberRow.getByRole('button', { name: 'Promote to owner' });
await promoteButton.click();
await expect(page.getByText('Member promoted to owner successfully').first()).toBeVisible({
timeout: 10_000,
const updateRoleButton = memberRow.getByRole('button', {
name: 'Update role',
});
await updateRoleButton.click();
// Wait for dialog to open and select Owner role
await expect(page.getByRole('dialog')).toBeVisible();
// Find and click the select trigger - it's a button with role="combobox"
await page.getByRole('dialog').locator('button[role="combobox"]').click();
// Select "Owner" from the dropdown options
await page.getByRole('option', { name: 'Owner' }).click();
// Click Update button
await page.getByRole('dialog').getByRole('button', { name: 'Update' }).click();
// Wait for dialog to close (indicates success)
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10_000 });
// Test that the new owner can access organisation settings
await apiSignin({

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

@ -50,6 +50,7 @@ type UseEditorFieldsResponse = {
// Field operations
addField: (field: Omit<TLocalField, 'formId'>) => TLocalField;
setFieldId: (formId: string, id: number) => void;
removeFieldsByFormId: (formIds: string[]) => void;
updateFieldByFormId: (formId: string, updates: Partial<TLocalField>) => void;
duplicateField: (field: TLocalField, recipientId?: number) => TLocalField;
@ -123,7 +124,6 @@ export const useEditorFields = ({
}
if (bypassCheck) {
console.log(3);
setSelectedFieldFormId(formId);
return;
}
@ -136,6 +136,7 @@ export const useEditorFields = ({
const field: TLocalField = {
...fieldData,
formId: nanoid(12),
...restrictFieldPosValues(fieldData),
};
append(field);
@ -160,12 +161,31 @@ export const useEditorFields = ({
[localFields, remove, triggerFieldsUpdate],
);
const setFieldId = (formId: string, id: number) => {
const index = localFields.findIndex((field) => field.formId === formId);
if (index !== -1) {
update(index, {
...localFields[index],
id,
});
}
};
const updateFieldByFormId = useCallback(
(formId: string, updates: Partial<TLocalField>) => {
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();
}
},
@ -261,6 +281,7 @@ export const useEditorFields = ({
// Field operations
addField,
setFieldId,
removeFieldsByFormId,
updateFieldByFormId,
duplicateField,
@ -279,3 +300,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,15 @@ export const EnvelopeEditorProvider = ({
const { toast } = useToast();
const [envelope, setEnvelope] = useState(initialEnvelope);
const [autosaveError, setAutosaveError] = useState<boolean>(false);
const editorFields = useEditorFields({
envelope,
handleFieldsUpdate: (fields) => setFieldsDebounced(fields),
});
const envelopeUpdateMutationQuery = trpc.envelope.update.useMutation({
onSuccess: (response, input) => {
console.log(input.meta?.emailSettings);
setEnvelope({
...envelope,
...response,
@ -106,7 +118,9 @@ export const EnvelopeEditorProvider = ({
setAutosaveError(false);
},
onError: (error) => {
onError: (err) => {
console.error(err);
setAutosaveError(true);
toast({
@ -122,7 +136,9 @@ export const EnvelopeEditorProvider = ({
onSuccess: () => {
setAutosaveError(false);
},
onError: (error) => {
onError: (err) => {
console.error(err);
setAutosaveError(true);
toast({
@ -135,10 +151,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({
@ -166,63 +189,65 @@ export const EnvelopeEditorProvider = ({
triggerSave: setFieldsDebounced,
flush: setFieldsAsync,
isPending: isFieldsMutationPending,
} = useEnvelopeAutosave(async (fields: TLocalField[]) => {
await envelopeFieldSetMutationQuery.mutateAsync({
} = useEnvelopeAutosave(async (localFields: TLocalField[]) => {
const envelopeFields = await envelopeFieldSetMutationQuery.mutateAsync({
envelopeId: envelope.id,
envelopeType: envelope.type,
fields,
fields: localFields,
});
}, 1000);
// Insert the IDs into the local fields.
envelopeFields.fields.forEach((field) => {
const localField = localFields.find((localField) => localField.formId === field.formId);
if (localField && !localField.id) {
localField.id = field.id;
editorFields.setFieldId(localField.formId, field.id);
}
});
}, 2000);
const {
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);
};
const editorFields = useEditorFields({
envelope,
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 +259,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 +296,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 +322,6 @@ export const EnvelopeEditorProvider = ({
isDocument: envelope.type === EnvelopeType.DOCUMENT,
isTemplate: envelope.type === EnvelopeType.TEMPLATE,
setLocalEnvelope,
getFieldColor,
getRecipientColorKey,
updateEnvelope,
setRecipientsDebounced,
@ -278,6 +330,8 @@ export const EnvelopeEditorProvider = ({
autosaveError,
flushAutosave,
isAutosaving,
relativePath,
syncEnvelope,
}}
>
{children}

View File

@ -3,6 +3,9 @@ import React from 'react';
import type { DocumentData } from '@prisma/client';
import type { TRecipientColor } from '@documenso/ui/lib/recipient-colors';
import { AVAILABLE_RECIPIENT_COLORS } from '@documenso/ui/lib/recipient-colors';
import type { TEnvelope } from '../../types/envelope';
import { getFile } from '../../universal/upload/get-file';
@ -23,6 +26,7 @@ type EnvelopeRenderProviderValue = {
currentEnvelopeItem: EnvelopeRenderItem | null;
setCurrentEnvelopeItem: (envelopeItemId: string) => void;
fields: TEnvelope['fields'];
getRecipientColorKey: (recipientId: number) => TRecipientColor;
};
interface EnvelopeRenderProviderProps {
@ -35,6 +39,13 @@ interface EnvelopeRenderProviderProps {
* Only pass if the CustomRenderer you are passing in wants fields.
*/
fields?: TEnvelope['fields'];
/**
* Optional recipient IDs used to determine the color of the fields.
*
* Only required for generic page renderers.
*/
recipientIds?: number[];
}
const EnvelopeRenderContext = createContext<EnvelopeRenderProviderValue | null>(null);
@ -56,6 +67,7 @@ export const EnvelopeRenderProvider = ({
children,
envelope,
fields,
recipientIds = [],
}: EnvelopeRenderProviderProps) => {
// Indexed by documentDataId.
const [files, setFiles] = useState<Record<string, FileData>>({});
@ -132,6 +144,17 @@ export const EnvelopeRenderProvider = ({
}
}, [envelope.envelopeItems]);
const getRecipientColorKey = useCallback(
(recipientId: number) => {
const recipientIndex = recipientIds.findIndex((id) => id === recipientId);
return AVAILABLE_RECIPIENT_COLORS[
Math.max(recipientIndex, 0) % AVAILABLE_RECIPIENT_COLORS.length
];
},
[recipientIds],
);
return (
<EnvelopeRenderContext.Provider
value={{
@ -140,6 +163,7 @@ export const EnvelopeRenderProvider = ({
currentEnvelopeItem: currentItem,
setCurrentEnvelopeItem,
fields: fields ?? [],
getRecipientColorKey,
}}
>
{children}

View File

@ -14,3 +14,5 @@ export const IS_BILLING_ENABLED = () => env('NEXT_PUBLIC_FEATURE_BILLING_ENABLED
export const API_V2_BETA_URL = '/api/v2-beta';
export const SUPPORT_EMAIL = env('NEXT_PUBLIC_SUPPORT_EMAIL') ?? 'support@documenso.com';
export const IS_ENVELOPES_ENABLED = env('NEXT_PUBLIC_FEATURE_ENVELOPES_ENABLED') === 'true';

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';
@ -180,7 +191,7 @@ export const run = async ({
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 +364,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

@ -1,7 +1,5 @@
import { EnvelopeType, TeamMemberRole } from '@prisma/client';
import type { Prisma, User } from '@prisma/client';
import { SigningStatus } from '@prisma/client';
import { DocumentVisibility } from '@prisma/client';
import { DocumentVisibility, EnvelopeType, SigningStatus, TeamMemberRole } from '@prisma/client';
import { DateTime } from 'luxon';
import { match } from 'ts-pattern';
@ -215,13 +213,14 @@ const getTeamCounts = async (options: GetTeamCountsOption) => {
],
};
const rootPageFilter = folderId === undefined ? { folderId: null } : {};
let ownerCountsWhereInput: Prisma.EnvelopeWhereInput = {
type: EnvelopeType.DOCUMENT,
userId: userIdWhereClause,
createdAt,
teamId,
deletedAt: null,
folderId,
};
let notSignedCountsGroupByArgs = null;
@ -265,8 +264,16 @@ const getTeamCounts = async (options: GetTeamCountsOption) => {
ownerCountsWhereInput = {
...ownerCountsWhereInput,
...visibilityFiltersWhereInput,
...searchFilter,
AND: [
...(Array.isArray(visibilityFiltersWhereInput.AND)
? visibilityFiltersWhereInput.AND
: visibilityFiltersWhereInput.AND
? [visibilityFiltersWhereInput.AND]
: []),
searchFilter,
rootPageFilter,
folderId ? { folderId } : {},
],
};
if (teamEmail) {
@ -285,6 +292,7 @@ const getTeamCounts = async (options: GetTeamCountsOption) => {
},
],
deletedAt: null,
AND: [searchFilter, rootPageFilter, folderId ? { folderId } : {}],
};
notSignedCountsGroupByArgs = {
@ -296,7 +304,6 @@ const getTeamCounts = async (options: GetTeamCountsOption) => {
type: EnvelopeType.DOCUMENT,
userId: userIdWhereClause,
createdAt,
folderId,
status: ExtendedDocumentStatus.PENDING,
recipients: {
some: {
@ -306,6 +313,7 @@ const getTeamCounts = async (options: GetTeamCountsOption) => {
},
},
deletedAt: null,
AND: [searchFilter, rootPageFilter, folderId ? { folderId } : {}],
},
} satisfies Prisma.EnvelopeGroupByArgs;
@ -318,7 +326,6 @@ const getTeamCounts = async (options: GetTeamCountsOption) => {
type: EnvelopeType.DOCUMENT,
userId: userIdWhereClause,
createdAt,
folderId,
OR: [
{
status: ExtendedDocumentStatus.PENDING,
@ -342,6 +349,7 @@ const getTeamCounts = async (options: GetTeamCountsOption) => {
},
},
],
AND: [searchFilter, rootPageFilter, folderId ? { folderId } : {}],
},
} satisfies Prisma.EnvelopeGroupByArgs;
}

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,37 @@ export const sendDocument = async ({
});
}
if (envelope.internalVersion === 2) {
const autoInsertedFields = await Promise.all(
fieldsToAutoInsert.map(async (field) => {
return await tx.field.update({
where: {
id: field.fieldId,
},
data: {
customText: field.customText,
inserted: true,
},
});
}),
);
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELDS_AUTO_INSERTED,
envelopeId: envelope.id,
data: {
fields: autoInsertedFields.map((field) => ({
fieldId: field.id,
fieldType: field.type,
recipientId: field.recipientId,
})),
},
// Don't put metadata or user here since it's a system event.
}),
});
}
return await tx.envelope.update({
where: {
id: envelope.id,

View File

@ -0,0 +1,50 @@
import { DocumentStatus } from '@prisma/client';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
import { buildTeamWhereQuery } from '../../utils/teams';
export type CreateAttachmentOptions = {
envelopeId: string;
teamId: number;
userId: number;
data: {
label: string;
data: string;
};
};
export const createAttachment = async ({
envelopeId,
teamId,
userId,
data,
}: CreateAttachmentOptions) => {
const envelope = await prisma.envelope.findFirst({
where: {
id: envelopeId,
team: buildTeamWhereQuery({ teamId, userId }),
},
});
if (!envelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Envelope not found',
});
}
if (envelope.status === DocumentStatus.COMPLETED || envelope.status === DocumentStatus.REJECTED) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Attachments can not be modified after the document has been completed or rejected',
});
}
return await prisma.envelopeAttachment.create({
data: {
envelopeId,
type: 'link',
...data,
},
});
};

View File

@ -0,0 +1,47 @@
import { DocumentStatus } from '@prisma/client';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
import { buildTeamWhereQuery } from '../../utils/teams';
export type DeleteAttachmentOptions = {
id: string;
userId: number;
teamId: number;
};
export const deleteAttachment = async ({ id, userId, teamId }: DeleteAttachmentOptions) => {
const attachment = await prisma.envelopeAttachment.findFirst({
where: {
id,
envelope: {
team: buildTeamWhereQuery({ teamId, userId }),
},
},
include: {
envelope: true,
},
});
if (!attachment) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Attachment not found',
});
}
if (
attachment.envelope.status === DocumentStatus.COMPLETED ||
attachment.envelope.status === DocumentStatus.REJECTED
) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Attachments can not be modified after the document has been completed or rejected',
});
}
await prisma.envelopeAttachment.delete({
where: {
id,
},
});
};

View File

@ -0,0 +1,38 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
import { buildTeamWhereQuery } from '../../utils/teams';
export type FindAttachmentsByEnvelopeIdOptions = {
envelopeId: string;
userId: number;
teamId: number;
};
export const findAttachmentsByEnvelopeId = async ({
envelopeId,
userId,
teamId,
}: FindAttachmentsByEnvelopeIdOptions) => {
const envelope = await prisma.envelope.findFirst({
where: {
id: envelopeId,
team: buildTeamWhereQuery({ teamId, userId }),
},
});
if (!envelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Envelope not found',
});
}
return await prisma.envelopeAttachment.findMany({
where: {
envelopeId,
},
orderBy: {
createdAt: 'asc',
},
});
};

View File

@ -0,0 +1,70 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
export type FindAttachmentsByTokenOptions = {
envelopeId: string;
token: string;
};
export const findAttachmentsByToken = async ({
envelopeId,
token,
}: FindAttachmentsByTokenOptions) => {
const envelope = await prisma.envelope.findFirst({
where: {
id: envelopeId,
recipients: {
some: {
token,
},
},
},
});
if (!envelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Envelope not found',
});
}
return await prisma.envelopeAttachment.findMany({
where: {
envelopeId,
},
orderBy: {
createdAt: 'asc',
},
});
};
export type FindAttachmentsByTeamOptions = {
envelopeId: string;
teamId: number;
};
export const findAttachmentsByTeam = async ({
envelopeId,
teamId,
}: FindAttachmentsByTeamOptions) => {
const envelope = await prisma.envelope.findFirst({
where: {
id: envelopeId,
teamId,
},
});
if (!envelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Envelope not found',
});
}
return await prisma.envelopeAttachment.findMany({
where: {
envelopeId,
},
orderBy: {
createdAt: 'asc',
},
});
};

View File

@ -0,0 +1,49 @@
import { DocumentStatus } from '@prisma/client';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
import { buildTeamWhereQuery } from '../../utils/teams';
export type UpdateAttachmentOptions = {
id: string;
userId: number;
teamId: number;
data: { label?: string; data?: string };
};
export const updateAttachment = async ({ id, teamId, userId, data }: UpdateAttachmentOptions) => {
const attachment = await prisma.envelopeAttachment.findFirst({
where: {
id,
envelope: {
team: buildTeamWhereQuery({ teamId, userId }),
},
},
include: {
envelope: true,
},
});
if (!attachment) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Attachment not found',
});
}
if (
attachment.envelope.status === DocumentStatus.COMPLETED ||
attachment.envelope.status === DocumentStatus.REJECTED
) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Attachments can not be modified after the document has been completed or rejected',
});
}
return await prisma.envelopeAttachment.update({
where: {
id,
},
data,
});
};

View File

@ -20,6 +20,7 @@ import type { TCreateEnvelopeRequest } from '@documenso/trpc/server/envelope-rou
import type { TDocumentAccessAuthTypes, TDocumentActionAuthTypes } from '../../types/document-auth';
import type { TDocumentFormValues } from '../../types/document-form-values';
import type { TEnvelopeAttachmentType } from '../../types/envelope-attachment';
import {
ZWebhookDocumentSchema,
mapEnvelopeToWebhookDocumentPayload,
@ -58,6 +59,11 @@ export type CreateEnvelopeOptions = {
recipients?: TCreateEnvelopeRequest['recipients'];
folderId?: string;
};
attachments?: Array<{
label: string;
data: string;
type?: TEnvelopeAttachmentType;
}>;
meta?: Partial<Omit<DocumentMeta, 'id'>>;
requestMetadata: ApiRequestMetadata;
};
@ -67,6 +73,7 @@ export const createEnvelope = async ({
teamId,
normalizePdf,
data,
attachments,
meta,
requestMetadata,
internalVersion,
@ -246,6 +253,15 @@ export const createEnvelope = async ({
})),
},
},
envelopeAttachments: {
createMany: {
data: (attachments || []).map((attachment) => ({
label: attachment.label,
data: attachment.data,
type: attachment.type ?? 'link',
})),
},
},
userId,
teamId,
authOptions,
@ -338,6 +354,7 @@ export const createEnvelope = async ({
fields: true,
folder: true,
envelopeItems: true,
envelopeAttachments: true,
},
});

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) {
@ -304,7 +306,10 @@ export const setFieldsForDocument = async ({
});
}
return upsertedField;
return {
...upsertedField,
formId: field.formId,
};
}),
);
});
@ -338,17 +343,25 @@ export const setFieldsForDocument = async ({
}
// Filter out fields that have been removed or have been updated.
const filteredFields = existingFields.filter((field) => {
const isRemoved = removedFields.find((removedField) => removedField.id === field.id);
const isUpdated = persistedFields.find((persistedField) => persistedField.id === field.id);
const mappedFilteredFields = existingFields
.filter((field) => {
const isRemoved = removedFields.find((removedField) => removedField.id === field.id);
const isUpdated = persistedFields.find((persistedField) => persistedField.id === field.id);
return !isRemoved && !isUpdated;
});
return !isRemoved && !isUpdated;
})
.map((field) => ({
...mapFieldToLegacyField(field, envelope),
formId: undefined,
}));
const mappedPersistentFields = persistedFields.map((field) => ({
...mapFieldToLegacyField(field, envelope),
formId: field?.formId,
}));
return {
fields: [...filteredFields, ...persistedFields].map((field) =>
mapFieldToLegacyField(field, envelope),
),
fields: [...mappedFilteredFields, ...mappedPersistentFields],
};
};
@ -357,6 +370,7 @@ export const setFieldsForDocument = async ({
*/
type FieldData = {
id?: number | null;
formId?: string;
envelopeItemId: string;
type: FieldType;
recipientId: number;

View File

@ -27,6 +27,7 @@ export type SetFieldsForTemplateOptions = {
id: EnvelopeIdOptions;
fields: {
id?: number | null;
formId?: string;
envelopeItemId: string;
type: FieldType;
recipientId: number;
@ -111,10 +112,10 @@ export const setFieldsForTemplate = async ({
};
});
const persistedFields = await prisma.$transaction(
const persistedFields = await Promise.all(
// Disabling as wrapping promises here causes type issues
// eslint-disable-next-line @typescript-eslint/promise-function-async
linkedFields.map((field) => {
linkedFields.map(async (field) => {
const parsedFieldMeta = field.fieldMeta ? ZFieldMetaSchema.parse(field.fieldMeta) : undefined;
if (field.type === FieldType.TEXT && field.fieldMeta) {
@ -176,7 +177,7 @@ export const setFieldsForTemplate = async ({
}
// Proceed with upsert operation
return prisma.field.upsert({
const upsertedField = await prisma.field.upsert({
where: {
id: field._persisted?.id ?? -1,
envelopeId: envelope.id,
@ -219,6 +220,11 @@ export const setFieldsForTemplate = async ({
},
},
});
return {
...upsertedField,
formId: field.formId,
};
}),
);
@ -240,9 +246,17 @@ export const setFieldsForTemplate = async ({
return !isRemoved && !isUpdated;
});
const mappedFilteredFields = filteredFields.map((field) => ({
...mapFieldToLegacyField(field, envelope),
formId: undefined,
}));
const mappedPersistentFields = persistedFields.map((field) => ({
...mapFieldToLegacyField(field, envelope),
formId: field?.formId,
}));
return {
fields: [...filteredFields, ...persistedFields].map((field) =>
mapFieldToLegacyField(field, envelope),
),
fields: [...mappedFilteredFields, ...mappedPersistentFields],
};
};

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,
@ -640,6 +634,23 @@ export const createDocumentFromDirectTemplate = async ({
data: auditLogsToCreate,
});
const templateAttachments = await tx.envelopeAttachment.findMany({
where: {
envelopeId: directTemplateEnvelope.id,
},
});
if (templateAttachments.length > 0) {
await tx.envelopeAttachment.createMany({
data: templateAttachments.map((attachment) => ({
envelopeId: createdEnvelope.id,
type: attachment.type,
label: attachment.label,
data: attachment.data,
})),
});
}
// Send email to template owner.
const emailTemplate = createElement(DocumentCreatedFromDirectTemplateEmailTemplate, {
recipientName: directRecipientEmail,

View File

@ -91,6 +91,12 @@ export type CreateDocumentFromTemplateOptions = {
envelopeItemId?: string;
}[];
attachments?: Array<{
label: string;
data: string;
type?: 'link';
}>;
/**
* Values that will override the predefined values in the template.
*/
@ -203,6 +209,7 @@ const getUpdatedFieldMeta = (field: Field, prefillField?: TFieldMetaPrefillField
type: 'radio',
label: field.label,
values: newValues,
direction: radioMeta.direction ?? 'vertical',
};
return meta;
@ -295,6 +302,7 @@ export const createDocumentFromTemplate = async ({
requestMetadata,
folderId,
prefillFields,
attachments,
}: CreateDocumentFromTemplateOptions) => {
const { envelopeWhereInput } = await getEnvelopeWhereInput({
id,
@ -388,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> = {};
@ -400,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;
@ -667,6 +677,33 @@ export const createDocumentFromTemplate = async ({
}),
});
const templateAttachments = await tx.envelopeAttachment.findMany({
where: {
envelopeId: template.id,
},
});
const attachmentsToCreate = [
...templateAttachments.map((attachment) => ({
envelopeId: envelope.id,
type: attachment.type,
label: attachment.label,
data: attachment.data,
})),
...(attachments || []).map((attachment) => ({
envelopeId: envelope.id,
type: attachment.type || 'link',
label: attachment.label,
data: attachment.data,
})),
];
if (attachmentsToCreate.length > 0) {
await tx.envelopeAttachment.createMany({
data: attachmentsToCreate,
});
}
const createdEnvelope = await tx.envelope.findFirst({
where: {
id: envelope.id,

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.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -21,10 +21,14 @@ export const ZDocumentAuditLogTypeSchema = z.enum([
'RECIPIENT_DELETED',
'RECIPIENT_UPDATED',
'ENVELOPE_ITEM_CREATED',
'ENVELOPE_ITEM_DELETED',
// Document events.
'DOCUMENT_COMPLETED', // When the document is sealed and fully completed.
'DOCUMENT_CREATED', // When the document is created.
'DOCUMENT_DELETED', // When the document is soft deleted.
'DOCUMENT_FIELDS_AUTO_INSERTED', // When a field is auto inserted during send due to default values (radio/dropdown/checkbox).
'DOCUMENT_FIELD_INSERTED', // When a field is inserted (signed/approved/etc) by a recipient.
'DOCUMENT_FIELD_UNINSERTED', // When a field is uninserted by a recipient.
'DOCUMENT_FIELD_PREFILLED', // When a field is prefilled by an assistant.
@ -181,6 +185,28 @@ const ZBaseRecipientDataSchema = z.object({
recipientRole: z.string(),
});
/**
* Event: Envelope item created.
*/
export const ZDocumentAuditLogEventEnvelopeItemCreatedSchema = z.object({
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.ENVELOPE_ITEM_CREATED),
data: z.object({
envelopeItemId: z.string(),
envelopeItemTitle: z.string(),
}),
});
/**
* Event: Envelope item deleted.
*/
export const ZDocumentAuditLogEventEnvelopeItemDeletedSchema = z.object({
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.ENVELOPE_ITEM_DELETED),
data: z.object({
envelopeItemId: z.string(),
envelopeItemTitle: z.string(),
}),
});
/**
* Event: Email sent.
*/
@ -315,6 +341,22 @@ export const ZDocumentAuditLogEventDocumentFieldInsertedSchema = z.object({
}),
});
/**
* Event: Document field auto inserted.
*/
export const ZDocumentAuditLogEventDocumentFieldsAutoInsertedSchema = z.object({
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELDS_AUTO_INSERTED),
data: z.object({
fields: z.array(
z.object({
fieldId: z.number(),
fieldType: z.nativeEnum(FieldType),
recipientId: z.number(),
}),
),
}),
});
/**
* Event: Document field uninserted.
*/
@ -652,11 +694,14 @@ export const ZDocumentAuditLogBaseSchema = z.object({
export const ZDocumentAuditLogSchema = ZDocumentAuditLogBaseSchema.and(
z.union([
ZDocumentAuditLogEventEnvelopeItemCreatedSchema,
ZDocumentAuditLogEventEnvelopeItemDeletedSchema,
ZDocumentAuditLogEventEmailSentSchema,
ZDocumentAuditLogEventDocumentCompletedSchema,
ZDocumentAuditLogEventDocumentCreatedSchema,
ZDocumentAuditLogEventDocumentDeletedSchema,
ZDocumentAuditLogEventDocumentMovedToTeamSchema,
ZDocumentAuditLogEventDocumentFieldsAutoInsertedSchema,
ZDocumentAuditLogEventDocumentFieldInsertedSchema,
ZDocumentAuditLogEventDocumentFieldUninsertedSchema,
ZDocumentAuditLogEventDocumentFieldPrefilledSchema,

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

@ -0,0 +1,5 @@
import { z } from 'zod';
export const ZEnvelopeAttachmentTypeSchema = z.enum(['link']);
export type TEnvelopeAttachmentType = z.infer<typeof ZEnvelopeAttachmentTypeSchema>;

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';
@ -69,7 +71,6 @@ export const ZFieldHeightSchema = z.number().min(1).describe('The height of the
// ---------------------------------------------
// Todo: Envelopes - dunno man
const PrismaDecimalSchema = z.preprocess(
(val) => (typeof val === 'string' ? new Prisma.Decimal(val) : val),
z.instanceof(Prisma.Decimal, { message: 'Must be a Decimal' }),
@ -91,7 +92,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)',
@ -117,3 +129,58 @@ export const createSpinner = ({
return loadingGroup;
};
type CreateFieldHoverInteractionOptions = {
options: RenderFieldElementOptions;
fieldGroup: Konva.Group;
fieldRect: Konva.Rect;
};
/**
* Adds smooth transition-like behavior for hover effects to the field group and rectangle.
*/
export const createFieldHoverInteraction = ({
options,
fieldGroup,
fieldRect,
}: CreateFieldHoverInteractionOptions) => {
const { mode } = options;
if (mode === 'export' || !options.color) {
return;
}
const hoverColor = RECIPIENT_COLOR_STYLES[options.color].baseRingHover;
fieldGroup.on('mouseover', () => {
new Konva.Tween({
node: fieldRect,
duration: 0.3,
fill: hoverColor,
}).play();
});
fieldGroup.on('mouseout', () => {
new Konva.Tween({
node: fieldRect,
duration: 0.3,
fill: DEFAULT_RECT_BACKGROUND,
}).play();
});
fieldGroup.on('transformstart', () => {
new Konva.Tween({
node: fieldRect,
duration: 0.3,
fill: hoverColor,
}).play();
});
fieldGroup.on('transformend', () => {
new Konva.Tween({
node: fieldRect,
duration: 0.3,
fill: DEFAULT_RECT_BACKGROUND,
}).play();
});
};

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,154 +1,184 @@
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 {
createFieldHoverInteraction,
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,
) => {
const { pageWidth, pageHeight, pageLayer, mode } = options;
const isFirstRender = !pageLayer.findOne(`#${field.renderId}`);
const fieldGroup = upsertFieldGroup(field, options);
// Clear previous children to re-render fresh
fieldGroup.removeChildren();
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 { fieldWidth, fieldHeight } = calculateFieldPosition(field, pageWidth, pageHeight);
const checkboxMeta: TCheckboxFieldMeta | null = (field.fieldMeta as TCheckboxFieldMeta) || null;
const checkboxValues = checkboxMeta?.values || [];
const { fieldWidth, fieldHeight } = calculateFieldPosition(field, pageWidth, pageHeight);
const isFirstRender = !pageLayer.findOne(`#${field.renderId}`);
// Clear previous children and listeners to re-render fresh.
const fieldGroup = upsertFieldGroup(field, options);
fieldGroup.removeChildren();
fieldGroup.off('transform');
if (isFirstRender) {
pageLayer.add(fieldGroup);
}
const fieldRect = upsertFieldRect(field, options);
fieldGroup.add(fieldRect);
const fontSize = checkboxMeta?.fontSize || DEFAULT_STANDARD_FONT_SIZE;
// 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 checkedValues: number[] = field.customText ? JSON.parse(field.customText) : [];
checkboxValues.forEach(({ 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();
const itemSize = calculateCheckboxSize(fontSize);
checkboxValues.forEach(({ id, value, checked }, index) => {
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 +186,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 +199,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);
@ -180,6 +210,8 @@ export const renderCheckboxFieldElement = (
fieldGroup.add(text);
});
createFieldHoverInteraction({ fieldGroup, fieldRect, options });
return {
fieldGroup,
isFirstRender,

View File

@ -1,8 +1,15 @@
import { FieldType } from '@prisma/client';
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 {
createFieldHoverInteraction,
konvaTextFill,
konvaTextFontFamily,
upsertFieldGroup,
upsertFieldRect,
} from './field-generic-items';
import { calculateFieldPosition } from './field-renderer';
import type { FieldToRender, RenderFieldElementOptions } from './field-renderer';
@ -26,7 +33,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,
@ -43,76 +50,29 @@ export const renderDropdownFieldElement = (
field: FieldToRender,
options: RenderFieldElementOptions,
) => {
const { pageWidth, pageHeight, pageLayer, mode } = options;
const { pageWidth, pageHeight, pageLayer, mode, translations } = options;
const { fieldWidth, fieldHeight } = calculateFieldPosition(field, pageWidth, pageHeight);
const dropdownMeta: TDropdownFieldMeta | null = (field.fieldMeta as TDropdownFieldMeta) || null;
let selectedValue = translations?.[FieldType.DROPDOWN] || 'Select Option';
const isFirstRender = !pageLayer.findOne(`#${field.renderId}`);
const fieldGroup = upsertFieldGroup(field, options);
// Clear previous children to re-render fresh.
const fieldGroup = upsertFieldGroup(field, options);
fieldGroup.removeChildren();
fieldGroup.off('transform');
fieldGroup.add(upsertFieldRect(field, options));
const fieldRect = upsertFieldRect(field, options);
fieldGroup.add(fieldRect);
if (isFirstRender) {
pageLayer.add(fieldGroup);
fieldGroup.on('transform', () => {
const groupScaleX = fieldGroup.scaleX();
const groupScaleY = fieldGroup.scaleY();
const fieldRect = fieldGroup.findOne('.field-rect');
const text = fieldGroup.findOne('.dropdown-selected-text');
const arrow = fieldGroup.findOne('.dropdown-arrow');
if (!fieldRect || !text || !arrow) {
console.log('fieldRect or text or arrow not found');
return;
}
const rectWidth = fieldRect.width() * groupScaleX;
const rectHeight = fieldRect.height() * groupScaleY;
const { arrowX, arrowY, textX, textY, textWidth, textHeight } = calculateDropdownPosition({
fieldWidth: rectWidth,
fieldHeight: rectHeight,
});
arrow.setAttrs({
x: arrowX,
y: arrowY,
scaleX: 1,
scaleY: 1,
});
text.setAttrs({
scaleX: 1,
scaleY: 1,
x: textX,
y: textY,
width: textWidth,
height: textHeight,
});
fieldRect.setAttrs({
width: rectWidth,
height: rectHeight,
});
fieldGroup.scale({
x: 1,
y: 1,
});
pageLayer.batchDraw();
});
}
const dropdownMeta: TDropdownFieldMeta | null = (field.fieldMeta as TDropdownFieldMeta) || null;
const { fieldWidth, fieldHeight } = calculateFieldPosition(field, pageWidth, pageHeight);
// Todo: Envelopes - Translations
let selectedValue = 'Select Option';
const fontSize = dropdownMeta?.fontSize || DEFAULT_STANDARD_FONT_SIZE;
if (field.inserted) {
selectedValue = field.customText;
@ -132,9 +92,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',
});
@ -151,27 +111,63 @@ export const renderDropdownFieldElement = (
visible: mode !== 'export',
});
// Add hover state for dropdown
fieldGroup.on('mouseenter', () => {
// dropdownContainer.stroke('#2563EB');
// dropdownContainer.strokeWidth(2);
document.body.style.cursor = 'pointer';
pageLayer.batchDraw();
});
fieldGroup.on('mouseleave', () => {
// dropdownContainer.stroke('#374151');
// dropdownContainer.strokeWidth(2);
document.body.style.cursor = 'default';
pageLayer.batchDraw();
});
fieldGroup.add(selectedText);
if (!field.inserted || mode === 'export') {
fieldGroup.add(arrow);
}
fieldGroup.on('transform', () => {
const groupScaleX = fieldGroup.scaleX();
const groupScaleY = fieldGroup.scaleY();
const fieldRect = fieldGroup.findOne('.field-rect');
const text = fieldGroup.findOne('.dropdown-selected-text');
const arrow = fieldGroup.findOne('.dropdown-arrow');
if (!fieldRect || !text || !arrow) {
return;
}
const rectWidth = fieldRect.width() * groupScaleX;
const rectHeight = fieldRect.height() * groupScaleY;
const { arrowX, arrowY, textX, textY, textWidth, textHeight } = calculateDropdownPosition({
fieldWidth: rectWidth,
fieldHeight: rectHeight,
});
arrow.setAttrs({
x: arrowX,
y: arrowY,
scaleX: 1,
scaleY: 1,
});
text.setAttrs({
scaleX: 1,
scaleY: 1,
x: textX,
y: textY,
width: textWidth,
height: textHeight,
});
fieldRect.setAttrs({
width: rectWidth,
height: rectHeight,
});
fieldGroup.scale({
x: 1,
y: 1,
});
pageLayer.batchDraw();
});
createFieldHoverInteraction({ fieldGroup, fieldRect, options });
return {
fieldGroup,
isFirstRender,

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,135 +1,163 @@
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 {
createFieldHoverInteraction,
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,
) => {
const { pageWidth, pageHeight, pageLayer, mode } = options;
const radioMeta: TRadioFieldMeta | null = (field.fieldMeta as TRadioFieldMeta) || null;
const radioValues = radioMeta?.values || [];
const isFirstRender = !pageLayer.findOne(`#${field.renderId}`);
// Clear previous children and listeners to re-render fresh
const fieldGroup = upsertFieldGroup(field, options);
// Clear previous children to re-render fresh
fieldGroup.removeChildren();
fieldGroup.add(upsertFieldRect(field, options));
fieldGroup.off('transform');
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 fieldRect = upsertFieldRect(field, options);
fieldGroup.add(fieldRect);
const fontSize = radioMeta?.fontSize || DEFAULT_STANDARD_FONT_SIZE;
// 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);
@ -169,6 +195,8 @@ export const renderRadioFieldElement = (
fieldGroup.add(text);
});
createFieldHoverInteraction({ fieldGroup, fieldRect, options });
return {
fieldGroup,
isFirstRender,

View File

@ -1,62 +1,77 @@
import Konva from 'konva';
import {
DEFAULT_RECT_BACKGROUND,
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 {
createFieldHoverInteraction,
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 +80,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,111 +148,70 @@ 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.
fieldRect.opacity(0);
}
// Todo: Doesn't work.
if (mode !== 'export') {
const hoverColor = options.color
? RECIPIENT_COLOR_STYLES[options.color].baseRingHover
: '#e5e7eb';
// Todo: Envelopes - On hover add text color
// Add smooth transition-like behavior for hover effects
fieldGroup.on('mouseover', () => {
new Konva.Tween({
node: fieldRect,
duration: 0.3,
fill: hoverColor,
}).play();
});
fieldGroup.on('mouseout', () => {
new Konva.Tween({
node: fieldRect,
duration: 0.3,
fill: DEFAULT_RECT_BACKGROUND,
}).play();
});
fieldGroup.add(fieldRect);
}
createFieldHoverInteraction({ fieldGroup, fieldRect, options });
return {
fieldGroup,

View File

@ -1,23 +1,26 @@
import Konva from 'konva';
import {
DEFAULT_RECT_BACKGROUND,
RECIPIENT_COLOR_STYLES,
} from '@documenso/ui/lib/recipient-colors';
import { DEFAULT_STANDARD_FONT_SIZE } from '../../constants/pdf';
import type { TTextFieldMeta } from '../../types/field-meta';
import { upsertFieldGroup, upsertFieldRect } from './field-generic-items';
import {
createFieldHoverInteraction,
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 { fieldWidth, fieldHeight } = calculateFieldPosition(field, pageWidth, pageHeight);
const textMeta = field.fieldMeta as TTextFieldMeta | undefined;
const fieldTypeName = translations?.[field.type] || field.type;
const fieldText: Konva.Text =
pageLayer.findOne(`#${field.renderId}-text`) ||
new Konva.Text({
@ -30,24 +33,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 +59,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 +79,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 +94,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,
@ -119,79 +114,63 @@ export const renderTextFieldElement = (
const isFirstRender = !pageLayer.findOne(`#${field.renderId}`);
// Clear previous children and listeners to re-render fresh.
const fieldGroup = upsertFieldGroup(field, options);
fieldGroup.removeChildren();
fieldGroup.off('transform');
// ABOVE IS GENERIC, EXTRACT IT.
// 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') {
@ -199,33 +178,7 @@ export const renderTextFieldElement = (
fieldRect.opacity(0);
}
// Todo: Doesn't work.
if (mode !== 'export') {
const hoverColor = options.color
? RECIPIENT_COLOR_STYLES[options.color].baseRingHover
: '#e5e7eb';
// Todo: Envelopes - On hover add text color
// Add smooth transition-like behavior for hover effects
fieldGroup.on('mouseover', () => {
new Konva.Tween({
node: fieldRect,
duration: 0.3,
fill: hoverColor,
}).play();
});
fieldGroup.on('mouseout', () => {
new Konva.Tween({
node: fieldRect,
duration: 0.3,
fill: DEFAULT_RECT_BACKGROUND,
}).play();
});
fieldGroup.add(fieldRect);
}
createFieldHoverInteraction({ fieldGroup, fieldRect, options });
return {
fieldGroup,

View File

@ -353,6 +353,13 @@ export const formatDocumentAuditLogAction = (
}),
identified: msg`${prefix} deleted the document`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELDS_AUTO_INSERTED }, () => ({
anonymous: msg({
message: `System auto inserted fields`,
context: `Audit log format`,
}),
identified: msg`System auto inserted fields`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_INSERTED }, () => ({
anonymous: msg({
message: `Field signed`,
@ -515,6 +522,14 @@ export const formatDocumentAuditLogAction = (
context: `Audit log format`,
}),
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.ENVELOPE_ITEM_CREATED }, ({ data }) => ({
anonymous: msg`Envelope item created`,
identified: msg`${prefix} created an envelope item with title ${data.envelopeItemTitle}`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.ENVELOPE_ITEM_DELETED }, ({ data }) => ({
anonymous: msg`Envelope item deleted`,
identified: msg`${prefix} deleted an envelope item with title ${data.envelopeItemTitle}`,
}))
.exhaustive();
return {

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`Select Option`),
[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,15 @@
-- CreateTable
CREATE TABLE "EnvelopeAttachment" (
"id" TEXT NOT NULL,
"type" TEXT NOT NULL,
"label" TEXT NOT NULL,
"data" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"envelopeId" TEXT NOT NULL,
CONSTRAINT "EnvelopeAttachment_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "EnvelopeAttachment" ADD CONSTRAINT "EnvelopeAttachment_envelopeId_fkey" FOREIGN KEY ("envelopeId") REFERENCES "Envelope"("id") ON DELETE CASCADE ON UPDATE CASCADE;

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)
}
@ -422,6 +424,8 @@ model Envelope {
documentMetaId String @unique
documentMeta DocumentMeta @relation(fields: [documentMetaId], references: [id])
envelopeAttachments EnvelopeAttachment[]
}
model EnvelopeItem {
@ -508,6 +512,22 @@ model DocumentMeta {
envelope Envelope?
}
/// @zod.import(["import { ZEnvelopeAttachmentTypeSchema } from '@documenso/lib/types/envelope-attachment';"])
model EnvelopeAttachment {
id String @id @default(cuid())
type String /// [EnvelopeAttachmentType] @zod.custom.use(ZEnvelopeAttachmentTypeSchema)
label String
data String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
envelopeId String
envelope Envelope @relation(fields: [envelopeId], references: [id], onDelete: Cascade)
}
enum ReadStatus {
NOT_OPENED
OPENED

View File

@ -5,6 +5,7 @@ import type {
} from '@documenso/lib/types/document-auth';
import type { TDocumentEmailSettings } from '@documenso/lib/types/document-email';
import type { TDocumentFormValues } from '@documenso/lib/types/document-form-values';
import type { TEnvelopeAttachmentType } from '@documenso/lib/types/envelope-attachment';
import type { TFieldMetaNotOptionalSchema } from '@documenso/lib/types/field-meta';
import type { TClaimFlags } from '@documenso/lib/types/subscription';
@ -23,6 +24,8 @@ declare global {
type RecipientAuthOptions = TRecipientAuthOptions;
type FieldMeta = TFieldMetaNotOptionalSchema;
type EnvelopeAttachmentType = TEnvelopeAttachmentType;
}
}

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

@ -39,6 +39,11 @@ export const getAdminOrganisation = async ({ organisationId }: GetOrganisationOp
teams: true,
members: {
include: {
organisationGroupMembers: {
include: {
group: true,
},
},
user: {
select: {
id: true,

View File

@ -3,6 +3,8 @@ import { z } from 'zod';
import { ZOrganisationSchema } from '@documenso/lib/types/organisation';
import OrganisationClaimSchema from '@documenso/prisma/generated/zod/modelSchema/OrganisationClaimSchema';
import OrganisationGlobalSettingsSchema from '@documenso/prisma/generated/zod/modelSchema/OrganisationGlobalSettingsSchema';
import OrganisationGroupMemberSchema from '@documenso/prisma/generated/zod/modelSchema/OrganisationGroupMemberSchema';
import OrganisationGroupSchema from '@documenso/prisma/generated/zod/modelSchema/OrganisationGroupSchema';
import OrganisationMemberSchema from '@documenso/prisma/generated/zod/modelSchema/OrganisationMemberSchema';
import SubscriptionSchema from '@documenso/prisma/generated/zod/modelSchema/SubscriptionSchema';
import TeamSchema from '@documenso/prisma/generated/zod/modelSchema/TeamSchema';
@ -30,6 +32,18 @@ export const ZGetAdminOrganisationResponseSchema = ZOrganisationSchema.extend({
email: true,
name: true,
}),
organisationGroupMembers: z.array(
OrganisationGroupMemberSchema.pick({
id: true,
groupId: true,
}).extend({
group: OrganisationGroupSchema.pick({
id: true,
type: true,
organisationRole: true,
}),
}),
),
}).array(),
subscription: SubscriptionSchema.nullable(),
organisationClaim: OrganisationClaimSchema,

View File

@ -17,6 +17,7 @@ import { promoteMemberToOwnerRoute } from './promote-member-to-owner';
import { resealDocumentRoute } from './reseal-document';
import { resetTwoFactorRoute } from './reset-two-factor-authentication';
import { updateAdminOrganisationRoute } from './update-admin-organisation';
import { updateOrganisationMemberRoleRoute } from './update-organisation-member-role';
import { updateRecipientRoute } from './update-recipient';
import { updateSiteSettingRoute } from './update-site-setting';
import { updateSubscriptionClaimRoute } from './update-subscription-claim';
@ -31,6 +32,7 @@ export const adminRouter = router({
},
organisationMember: {
promoteToOwner: promoteMemberToOwnerRoute,
updateRole: updateOrganisationMemberRoleRoute,
},
claims: {
find: findSubscriptionClaimsRoute,

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

@ -0,0 +1,220 @@
import { OrganisationGroupType, OrganisationMemberRole } from '@prisma/client';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { generateDatabaseId } from '@documenso/lib/universal/id';
import { getHighestOrganisationRoleInGroup } from '@documenso/lib/utils/organisations';
import { prisma } from '@documenso/prisma';
import { adminProcedure } from '../trpc';
import {
ZUpdateOrganisationMemberRoleRequestSchema,
ZUpdateOrganisationMemberRoleResponseSchema,
} from './update-organisation-member-role.types';
/**
* Admin mutation to update organisation member role or transfer ownership.
*
* This mutation handles two scenarios:
* 1. When role='OWNER': Transfers organisation ownership and promotes to ADMIN
* 2. When role=ADMIN/MANAGER/MEMBER: Updates group membership
*
* Admin privileges bypass normal hierarchy restrictions.
*/
export const updateOrganisationMemberRoleRoute = adminProcedure
.input(ZUpdateOrganisationMemberRoleRequestSchema)
.output(ZUpdateOrganisationMemberRoleResponseSchema)
.mutation(async ({ input, ctx }) => {
const { organisationId, userId, role } = input;
ctx.logger.info({
input: {
organisationId,
userId,
role,
},
});
const organisation = await prisma.organisation.findUnique({
where: {
id: organisationId,
},
include: {
groups: {
where: {
type: OrganisationGroupType.INTERNAL_ORGANISATION,
},
},
members: {
where: {
userId,
},
include: {
organisationGroupMembers: {
include: {
group: true,
},
},
},
},
},
});
if (!organisation) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Organisation not found',
});
}
const [member] = organisation.members;
if (!member) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'User is not a member of this organisation',
});
}
const currentOrganisationRole = getHighestOrganisationRoleInGroup(
member.organisationGroupMembers.flatMap((member) => member.group),
);
if (role === 'OWNER') {
if (organisation.ownerUserId === userId) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'User is already the owner of this organisation',
});
}
const currentMemberGroup = organisation.groups.find(
(group) => group.organisationRole === currentOrganisationRole,
);
const adminGroup = organisation.groups.find(
(group) => group.organisationRole === OrganisationMemberRole.ADMIN,
);
if (!currentMemberGroup) {
ctx.logger.error({
message: '[CRITICAL]: Missing internal group',
organisationId,
userId,
role: currentOrganisationRole,
});
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
message: 'Current member group not found',
});
}
if (!adminGroup) {
ctx.logger.error({
message: '[CRITICAL]: Missing internal group',
organisationId,
userId,
targetRole: 'ADMIN',
});
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
message: 'Admin group not found',
});
}
await prisma.$transaction(async (tx) => {
await tx.organisation.update({
where: {
id: organisationId,
},
data: {
ownerUserId: userId,
},
});
if (currentOrganisationRole !== OrganisationMemberRole.ADMIN) {
await tx.organisationGroupMember.delete({
where: {
organisationMemberId_groupId: {
organisationMemberId: member.id,
groupId: currentMemberGroup.id,
},
},
});
await tx.organisationGroupMember.create({
data: {
id: generateDatabaseId('group_member'),
organisationMemberId: member.id,
groupId: adminGroup.id,
},
});
}
});
return;
}
const targetRole = role as OrganisationMemberRole;
if (currentOrganisationRole === targetRole) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'User already has this role',
});
}
if (userId === organisation.ownerUserId && targetRole !== OrganisationMemberRole.ADMIN) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Organisation owner must be an admin. Transfer ownership first.',
});
}
const currentMemberGroup = organisation.groups.find(
(group) => group.organisationRole === currentOrganisationRole,
);
const newMemberGroup = organisation.groups.find(
(group) => group.organisationRole === targetRole,
);
if (!currentMemberGroup) {
ctx.logger.error({
message: '[CRITICAL]: Missing internal group',
organisationId,
userId,
role: currentOrganisationRole,
});
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
message: 'Current member group not found',
});
}
if (!newMemberGroup) {
ctx.logger.error({
message: '[CRITICAL]: Missing internal group',
organisationId,
userId,
targetRole,
});
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
message: 'New member group not found',
});
}
await prisma.$transaction(async (tx) => {
await tx.organisationGroupMember.delete({
where: {
organisationMemberId_groupId: {
organisationMemberId: member.id,
groupId: currentMemberGroup.id,
},
},
});
await tx.organisationGroupMember.create({
data: {
id: generateDatabaseId('group_member'),
organisationMemberId: member.id,
groupId: newMemberGroup.id,
},
});
});
});

View File

@ -0,0 +1,30 @@
import { OrganisationMemberRole } from '@prisma/client';
import { z } from 'zod';
/**
* Admin-only role selection that includes OWNER as a special case.
* OWNER is not a database role but triggers ownership transfer.
*/
export const ZAdminRoleSelection = z.enum([
'OWNER',
OrganisationMemberRole.ADMIN,
OrganisationMemberRole.MANAGER,
OrganisationMemberRole.MEMBER,
]);
export type TAdminRoleSelection = z.infer<typeof ZAdminRoleSelection>;
export const ZUpdateOrganisationMemberRoleRequestSchema = z.object({
organisationId: z.string().min(1),
userId: z.number().min(1),
role: ZAdminRoleSelection,
});
export const ZUpdateOrganisationMemberRoleResponseSchema = z.void();
export type TUpdateOrganisationMemberRoleRequest = z.infer<
typeof ZUpdateOrganisationMemberRoleRequestSchema
>;
export type TUpdateOrganisationMemberRoleResponse = z.infer<
typeof ZUpdateOrganisationMemberRoleResponseSchema
>;

View File

@ -0,0 +1,50 @@
import { EnvelopeType } from '@prisma/client';
import { createAttachment } from '@documenso/lib/server-only/envelope-attachment/create-attachment';
import { getEnvelopeById } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
import { authenticatedProcedure } from '../../trpc';
import {
ZCreateAttachmentRequestSchema,
ZCreateAttachmentResponseSchema,
} from './create-attachment.types';
export const createAttachmentRoute = authenticatedProcedure
.meta({
openapi: {
method: 'POST',
path: '/document/attachment/create',
summary: 'Create attachment',
description: 'Create a new attachment for a document',
tags: ['Document'],
},
})
.input(ZCreateAttachmentRequestSchema)
.output(ZCreateAttachmentResponseSchema)
.mutation(async ({ input, ctx }) => {
const { teamId } = ctx;
const userId = ctx.user.id;
const { documentId, data } = input;
ctx.logger.info({
input: { documentId, label: data.label },
});
const envelope = await getEnvelopeById({
id: {
type: 'documentId',
id: documentId,
},
userId,
teamId,
type: EnvelopeType.DOCUMENT,
});
await createAttachment({
envelopeId: envelope.id,
teamId,
userId,
data,
});
});

View File

@ -0,0 +1,14 @@
import { z } from 'zod';
export const ZCreateAttachmentRequestSchema = z.object({
documentId: z.number(),
data: z.object({
label: z.string().min(1, 'Label is required'),
data: z.string().url('Must be a valid URL'),
}),
});
export const ZCreateAttachmentResponseSchema = z.void();
export type TCreateAttachmentRequest = z.infer<typeof ZCreateAttachmentRequestSchema>;
export type TCreateAttachmentResponse = z.infer<typeof ZCreateAttachmentResponseSchema>;

View File

@ -0,0 +1,36 @@
import { deleteAttachment } from '@documenso/lib/server-only/envelope-attachment/delete-attachment';
import { authenticatedProcedure } from '../../trpc';
import {
ZDeleteAttachmentRequestSchema,
ZDeleteAttachmentResponseSchema,
} from './delete-attachment.types';
export const deleteAttachmentRoute = authenticatedProcedure
.meta({
openapi: {
method: 'POST',
path: '/document/attachment/delete',
summary: 'Delete attachment',
description: 'Delete an attachment from a document',
tags: ['Document'],
},
})
.input(ZDeleteAttachmentRequestSchema)
.output(ZDeleteAttachmentResponseSchema)
.mutation(async ({ input, ctx }) => {
const { teamId } = ctx;
const userId = ctx.user.id;
const { id } = input;
ctx.logger.info({
input: { id },
});
await deleteAttachment({
id,
userId,
teamId,
});
});

View File

@ -0,0 +1,10 @@
import { z } from 'zod';
export const ZDeleteAttachmentRequestSchema = z.object({
id: z.string(),
});
export const ZDeleteAttachmentResponseSchema = z.void();
export type TDeleteAttachmentRequest = z.infer<typeof ZDeleteAttachmentRequestSchema>;
export type TDeleteAttachmentResponse = z.infer<typeof ZDeleteAttachmentResponseSchema>;

View File

@ -0,0 +1,52 @@
import { EnvelopeType } from '@prisma/client';
import { findAttachmentsByEnvelopeId } from '@documenso/lib/server-only/envelope-attachment/find-attachments-by-envelope-id';
import { getEnvelopeById } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
import { authenticatedProcedure } from '../../trpc';
import {
ZFindAttachmentsRequestSchema,
ZFindAttachmentsResponseSchema,
} from './find-attachments.types';
export const findAttachmentsRoute = authenticatedProcedure
.meta({
openapi: {
method: 'GET',
path: '/document/attachment',
summary: 'Find attachments',
description: 'Find all attachments for a document',
tags: ['Document'],
},
})
.input(ZFindAttachmentsRequestSchema)
.output(ZFindAttachmentsResponseSchema)
.query(async ({ input, ctx }) => {
const { documentId } = input;
const { teamId } = ctx;
const userId = ctx.user.id;
ctx.logger.info({
input: { documentId },
});
const envelope = await getEnvelopeById({
id: {
type: 'documentId',
id: documentId,
},
userId,
teamId,
type: EnvelopeType.DOCUMENT,
});
const data = await findAttachmentsByEnvelopeId({
envelopeId: envelope.id,
teamId,
userId,
});
return {
data,
};
});

View File

@ -0,0 +1,21 @@
import { z } from 'zod';
import { ZEnvelopeAttachmentTypeSchema } from '@documenso/lib/types/envelope-attachment';
export const ZFindAttachmentsRequestSchema = z.object({
documentId: z.number(),
});
export const ZFindAttachmentsResponseSchema = z.object({
data: z.array(
z.object({
id: z.string(),
type: ZEnvelopeAttachmentTypeSchema,
label: z.string(),
data: z.string(),
}),
),
});
export type TFindAttachmentsRequest = z.infer<typeof ZFindAttachmentsRequestSchema>;
export type TFindAttachmentsResponse = z.infer<typeof ZFindAttachmentsResponseSchema>;

View File

@ -0,0 +1,37 @@
import { updateAttachment } from '@documenso/lib/server-only/envelope-attachment/update-attachment';
import { authenticatedProcedure } from '../../trpc';
import {
ZUpdateAttachmentRequestSchema,
ZUpdateAttachmentResponseSchema,
} from './update-attachment.types';
export const updateAttachmentRoute = authenticatedProcedure
.meta({
openapi: {
method: 'POST',
path: '/document/attachment/update',
summary: 'Update attachment',
description: 'Update an existing attachment',
tags: ['Document'],
},
})
.input(ZUpdateAttachmentRequestSchema)
.output(ZUpdateAttachmentResponseSchema)
.mutation(async ({ input, ctx }) => {
const { teamId } = ctx;
const userId = ctx.user.id;
const { id, data } = input;
ctx.logger.info({
input: { id },
});
await updateAttachment({
id,
userId,
teamId,
data,
});
});

View File

@ -0,0 +1,14 @@
import { z } from 'zod';
export const ZUpdateAttachmentRequestSchema = z.object({
id: z.string(),
data: z.object({
label: z.string().min(1, 'Label is required'),
data: z.string().url('Must be a valid URL'),
}),
});
export const ZUpdateAttachmentResponseSchema = z.void();
export type TUpdateAttachmentRequest = z.infer<typeof ZUpdateAttachmentRequestSchema>;
export type TUpdateAttachmentResponse = z.infer<typeof ZUpdateAttachmentResponseSchema>;

View File

@ -37,6 +37,7 @@ export const createDocumentTemporaryRoute = authenticatedProcedure
recipients,
meta,
folderId,
attachments,
} = input;
const { remaining } = await getServerLimits({ userId: user.id, teamId });
@ -82,10 +83,12 @@ 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,
},
],
},
attachments,
meta: {
...meta,
emailSettings: meta?.emailSettings ?? undefined,

View File

@ -7,6 +7,7 @@ import {
} from '@documenso/lib/types/document-auth';
import { ZDocumentFormValuesSchema } from '@documenso/lib/types/document-form-values';
import { ZDocumentMetaCreateSchema } from '@documenso/lib/types/document-meta';
import { ZEnvelopeAttachmentTypeSchema } from '@documenso/lib/types/envelope-attachment';
import {
ZFieldHeightSchema,
ZFieldPageNumberSchema,
@ -68,6 +69,15 @@ export const ZCreateDocumentTemporaryRequestSchema = z.object({
}),
)
.optional(),
attachments: z
.array(
z.object({
label: z.string().min(1, 'Label is required'),
data: z.string().url('Must be a valid URL'),
type: ZEnvelopeAttachmentTypeSchema.optional().default('link'),
}),
)
.optional(),
meta: ZDocumentMetaCreateSchema.optional(),
});

View File

@ -16,7 +16,7 @@ export const createDocumentRoute = authenticatedProcedure
.output(ZCreateDocumentResponseSchema)
.mutation(async ({ input, ctx }) => {
const { user, teamId } = ctx;
const { title, documentDataId, timezone, folderId } = input;
const { title, documentDataId, timezone, folderId, attachments } = input;
ctx.logger.info({
input: {
@ -44,10 +44,12 @@ 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,
},
],
},
attachments,
normalizePdf: true,
requestMetadata: ctx.metadata,
});

View File

@ -1,6 +1,7 @@
import { z } from 'zod';
import { ZDocumentMetaTimezoneSchema } from '@documenso/lib/types/document-meta';
import { ZEnvelopeAttachmentTypeSchema } from '@documenso/lib/types/envelope-attachment';
import { ZDocumentTitleSchema } from './schema';
@ -19,6 +20,15 @@ export const ZCreateDocumentRequestSchema = z.object({
documentDataId: z.string().min(1),
timezone: ZDocumentMetaTimezoneSchema.optional(),
folderId: z.string().describe('The ID of the folder to create the document in').optional(),
attachments: z
.array(
z.object({
label: z.string().min(1, 'Label is required'),
data: z.string().url('Must be a valid URL'),
type: ZEnvelopeAttachmentTypeSchema.optional().default('link'),
}),
)
.optional(),
});
export const ZCreateDocumentResponseSchema = z.object({

View File

@ -1,5 +1,9 @@
import { router } from '../trpc';
import { accessAuthRequest2FAEmailRoute } from './access-auth-request-2fa-email';
import { createAttachmentRoute } from './attachment/create-attachment';
import { deleteAttachmentRoute } from './attachment/delete-attachment';
import { findAttachmentsRoute } from './attachment/find-attachments';
import { updateAttachmentRoute } from './attachment/update-attachment';
import { createDocumentRoute } from './create-document';
import { createDocumentTemporaryRoute } from './create-document-temporary';
import { deleteDocumentRoute } from './delete-document';
@ -53,4 +57,10 @@ export const documentRouter = router({
find: findInboxRoute,
getCount: getInboxCountRoute,
}),
attachment: {
create: createAttachmentRoute,
update: updateAttachmentRoute,
delete: deleteAttachmentRoute,
find: findAttachmentsRoute,
},
});

View File

@ -0,0 +1,37 @@
import { createAttachment } from '@documenso/lib/server-only/envelope-attachment/create-attachment';
import { authenticatedProcedure } from '../../trpc';
import {
ZCreateAttachmentRequestSchema,
ZCreateAttachmentResponseSchema,
} from './create-attachment.types';
export const createAttachmentRoute = authenticatedProcedure
.meta({
openapi: {
method: 'POST',
path: '/envelope/attachment/create',
summary: 'Create attachment',
description: 'Create a new attachment for an envelope',
tags: ['Envelope'],
},
})
.input(ZCreateAttachmentRequestSchema)
.output(ZCreateAttachmentResponseSchema)
.mutation(async ({ input, ctx }) => {
const { teamId } = ctx;
const userId = ctx.user.id;
const { envelopeId, data } = input;
ctx.logger.info({
input: { envelopeId, label: data.label },
});
await createAttachment({
envelopeId,
teamId,
userId,
data,
});
});

View File

@ -0,0 +1,14 @@
import { z } from 'zod';
export const ZCreateAttachmentRequestSchema = z.object({
envelopeId: z.string(),
data: z.object({
label: z.string().min(1, 'Label is required'),
data: z.string().url('Must be a valid URL'),
}),
});
export const ZCreateAttachmentResponseSchema = z.void();
export type TCreateAttachmentRequest = z.infer<typeof ZCreateAttachmentRequestSchema>;
export type TCreateAttachmentResponse = z.infer<typeof ZCreateAttachmentResponseSchema>;

View File

@ -0,0 +1,36 @@
import { deleteAttachment } from '@documenso/lib/server-only/envelope-attachment/delete-attachment';
import { authenticatedProcedure } from '../../trpc';
import {
ZDeleteAttachmentRequestSchema,
ZDeleteAttachmentResponseSchema,
} from './delete-attachment.types';
export const deleteAttachmentRoute = authenticatedProcedure
.meta({
openapi: {
method: 'POST',
path: '/envelope/attachment/delete',
summary: 'Delete attachment',
description: 'Delete an attachment from an envelope',
tags: ['Envelope'],
},
})
.input(ZDeleteAttachmentRequestSchema)
.output(ZDeleteAttachmentResponseSchema)
.mutation(async ({ input, ctx }) => {
const { teamId } = ctx;
const userId = ctx.user.id;
const { id } = input;
ctx.logger.info({
input: { id },
});
await deleteAttachment({
id,
userId,
teamId,
});
});

View File

@ -0,0 +1,10 @@
import { z } from 'zod';
export const ZDeleteAttachmentRequestSchema = z.object({
id: z.string(),
});
export const ZDeleteAttachmentResponseSchema = z.void();
export type TDeleteAttachmentRequest = z.infer<typeof ZDeleteAttachmentRequestSchema>;
export type TDeleteAttachmentResponse = z.infer<typeof ZDeleteAttachmentResponseSchema>;

View File

@ -0,0 +1,52 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { findAttachmentsByEnvelopeId } from '@documenso/lib/server-only/envelope-attachment/find-attachments-by-envelope-id';
import { findAttachmentsByToken } from '@documenso/lib/server-only/envelope-attachment/find-attachments-by-token';
import { procedure } from '../../trpc';
import {
ZFindAttachmentsRequestSchema,
ZFindAttachmentsResponseSchema,
} from './find-attachments.types';
export const findAttachmentsRoute = procedure
.meta({
openapi: {
method: 'GET',
path: '/envelope/attachment',
summary: 'Find attachments',
description: 'Find all attachments for an envelope',
tags: ['Envelope'],
},
})
.input(ZFindAttachmentsRequestSchema)
.output(ZFindAttachmentsResponseSchema)
.query(async ({ input, ctx }) => {
const { envelopeId, token } = input;
ctx.logger.info({
input: { envelopeId },
});
if (token) {
const data = await findAttachmentsByToken({ envelopeId, token });
return {
data,
};
}
const { teamId } = ctx;
const userId = ctx.user?.id;
if (!userId || !teamId) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You must be authenticated to access this resource',
});
}
const data = await findAttachmentsByEnvelopeId({ envelopeId, teamId, userId });
return {
data,
};
});

View File

@ -0,0 +1,22 @@
import { z } from 'zod';
import { ZEnvelopeAttachmentTypeSchema } from '@documenso/lib/types/envelope-attachment';
export const ZFindAttachmentsRequestSchema = z.object({
envelopeId: z.string(),
token: z.string().optional(),
});
export const ZFindAttachmentsResponseSchema = z.object({
data: z.array(
z.object({
id: z.string(),
type: ZEnvelopeAttachmentTypeSchema,
label: z.string(),
data: z.string(),
}),
),
});
export type TFindAttachmentsRequest = z.infer<typeof ZFindAttachmentsRequestSchema>;
export type TFindAttachmentsResponse = z.infer<typeof ZFindAttachmentsResponseSchema>;

Some files were not shown because too many files have changed in this diff Show More