Compare commits

..

36 Commits

Author SHA1 Message Date
5fbad9e367 fix: merge conflicts 2025-11-12 12:26:14 +00:00
378dd605b9 v2.0.7 2025-11-12 13:09:18 +11:00
211ae6c9e9 fix: prefill arcoforms with formdata endpoints (#2169) 2025-11-12 12:41:10 +11:00
f931885a95 fix: remove redundant endpoint (#2170)
Remove duplicate redundant endpoint
2025-11-12 11:01:15 +11:00
4ade408001 fix: include cmaps for pdf viewer (#2177)
We were previously omitting cmaps meaning that
when signing documents with certain UTF-8 characters or CJK characters
they would appear as outlined squares in the pdf viewer despite the
actual pdf looking as expected with the characters displaying correctly.
2025-11-12 11:00:44 +11:00
3d0e3c6e8e fix: update dropzone to create envelopes (#2168) 2025-11-11 20:49:42 +11:00
936d8d90b3 fix: envelope auth not being passed (#2167) 2025-11-11 13:05:05 +11:00
c6b08d8594 feat: org insights (#1937) 2025-11-11 12:09:58 +11:00
575634e326 fix: add pluralization to document details (#2130) 2025-11-11 10:54:12 +11:00
c66eda4aae chore: add message context in user organisation table (#2138) 2025-11-11 10:44:20 +11:00
ef52b35f79 chore: change message in dropzone wrappers (#2137) 2025-11-11 10:43:22 +11:00
95a647034a chore: add message context in envelope download dialog (#2136) 2025-11-11 10:42:30 +11:00
34dba0b6ff chore: fix message formatting (#2135) 2025-11-11 10:41:54 +11:00
fccd97e124 fix: add pluralization to 2fa code template (#2131) 2025-11-11 10:39:05 +11:00
3dbbcefddf fix: add pluralization to passkey page (#2129) 2025-11-11 10:37:54 +11:00
2aea3c4de0 fix: rename envelope buttons (#2161) 2025-11-10 22:21:34 +11:00
ff44ffbc03 v2.0.6 2025-11-10 19:08:43 +11:00
441842d2bd fix: use correct token for embeded template files (#2160) 2025-11-10 19:08:08 +11:00
ca0b83579f fix: auto insert prefilled text and number fields (#2157) 2025-11-10 18:04:21 +11:00
6c0d1da91e fix(input): prevent mobile zoom on input focus (#2079) 2025-11-10 12:43:06 +11:00
805982f3e8 fix: envelope cc issues (#2158) 2025-11-10 11:42:57 +11:00
e2f5e570cf fix: envelope direct template (#2156) 2025-11-09 22:23:13 +11:00
9fd9613076 feat: add additional field options (#2154) 2025-11-08 23:40:03 +11:00
0977c16e33 v2.0.5 2025-11-08 16:03:59 +11:00
88d5a636c3 fix: show legacy ids on template and document view page (#2153)
<img width="557" height="455" alt="image"
src="https://github.com/user-attachments/assets/7b669f4a-c6c5-4fdc-bf10-da0def7b0b3f"
/>
2025-11-08 16:03:26 +11:00
1e6292b1d9 v2.0.4 2025-11-08 13:58:11 +11:00
d65866156d fix: remove parallel steps (#2152) 2025-11-08 13:57:26 +11:00
fe8915162f v2.0.3 2025-11-08 12:53:50 +11:00
37a2634aca feat: support optimizeParallelism for inngest jobs (#2151) 2025-11-08 12:53:13 +11:00
ac4b3737d6 feat: convert AI field placement to server-side processing 2025-11-06 01:43:04 +00:00
cdfd373958 feat: add glow animation 2025-10-31 06:03:38 +00:00
233e6e603c feat: multiple pages 2025-10-31 03:27:37 +00:00
134d5ac03e chore: refactor 2025-10-30 12:20:52 +00:00
00e33c5331 chore: cleanup 2025-10-30 00:33:59 +00:00
29be66a844 feat: add AI field auto-placement with canvas registry 2025-10-29 23:03:58 +00:00
94098bd762 feat: generates coordinates for bounding boxes 2025-10-29 18:14:16 +00:00
146 changed files with 7776 additions and 14181 deletions

View File

@ -135,6 +135,14 @@ NEXT_PUBLIC_FEATURE_BILLING_ENABLED=
# OPTIONAL: Leave blank to allow users to signup through /signup page.
NEXT_PUBLIC_DISABLE_SIGNUP=
# [[AI]]
# AI Gateway
AI_GATEWAY_API_KEY=""
# OPTIONAL: API key for Google Generative AI (Gemini). Get your key from https://ai.google.dev
GOOGLE_GENERATIVE_AI_API_KEY=""
# OPTIONAL: Enable AI field detection debug mode to save preview images with bounding boxes
NEXT_PUBLIC_AI_DEBUG_PREVIEW=
# [[E2E Tests]]
E2E_TEST_AUTHENTICATE_USERNAME="Test User"
E2E_TEST_AUTHENTICATE_USER_EMAIL="testuser@mail.com"

3
.gitignore vendored
View File

@ -60,3 +60,6 @@ CLAUDE.md
# agents
.specs
# ai debug previews
packages/assets/ai-previews/

View File

@ -336,7 +336,7 @@ export const EnvelopeDistributeDialog = ({
<Trans>Message</Trans>{' '}
<span className="text-muted-foreground">(Optional)</span>
<Tooltip>
<TooltipTrigger>
<TooltipTrigger type="button">
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-muted-foreground p-4">

View File

@ -176,7 +176,7 @@ export const EnvelopeDownloadDialog = ({
{!isDownloadingState[generateDownloadKey(item.id, 'original')] && (
<DownloadIcon className="mr-2 h-4 w-4" />
)}
<Trans>Original</Trans>
<Trans context="Original document (adjective)">Original</Trans>
</Button>
{envelopeStatus === DocumentStatus.COMPLETED && (
@ -190,7 +190,7 @@ export const EnvelopeDownloadDialog = ({
{!isDownloadingState[generateDownloadKey(item.id, 'signed')] && (
<DownloadIcon className="mr-2 h-4 w-4" />
)}
<Trans>Signed</Trans>
<Trans context="Signed document (adjective)">Signed</Trans>
</Button>
)}
</div>

View File

@ -3,7 +3,7 @@ import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { Plural, Trans } from '@lingui/react/macro';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { startRegistration } from '@simplewebauthn/browser';
import { KeyRoundIcon } from 'lucide-react';
@ -209,7 +209,11 @@ export const PasskeyCreateDialog = ({ trigger, onSuccess, ...props }: PasskeyCre
))
.with('TOO_MANY_PASSKEYS', () => (
<AlertDescription>
<Trans>You cannot have more than {MAXIMUM_PASSKEYS} passkeys.</Trans>
<Plural
value={MAXIMUM_PASSKEYS}
one="You cannot have more than # passkey."
other="You cannot have more than # passkeys."
/>
</AlertDescription>
))
.with('InvalidStateError', () => (

View File

@ -1,5 +1,4 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import { createCallable } from 'react-call';
@ -28,49 +27,71 @@ import {
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
const createNumberFieldSchema = (fieldMeta: TNumberFieldMeta) => {
let schema = z.coerce.number({
invalid_type_error: msg`Please enter a valid number`.id,
});
const { numberFormat, minValue, maxValue } = fieldMeta;
if (typeof minValue === 'number') {
schema = schema.min(minValue);
}
if (typeof maxValue === 'number') {
schema = schema.max(maxValue);
}
if (numberFormat) {
const foundRegex = numberFormatValues.find((item) => item.value === numberFormat)?.regex;
if (!foundRegex) {
return schema;
}
return schema.refine(
(value) => {
return foundRegex.test(value.toString());
},
{
message: msg`Number needs to be formatted as ${numberFormat}`.id,
},
);
}
return schema;
};
export type SignFieldNumberDialogProps = {
fieldMeta: TNumberFieldMeta;
};
export const SignFieldNumberDialog = createCallable<SignFieldNumberDialogProps, number | null>(
export const SignFieldNumberDialog = createCallable<SignFieldNumberDialogProps, string | null>(
({ call, fieldMeta }) => {
const { t } = useLingui();
// Needs to be inside dialog for translation purposes.
const createNumberFieldSchema = (fieldMeta: TNumberFieldMeta) => {
const { numberFormat, minValue, maxValue } = fieldMeta;
if (numberFormat) {
const foundRegex = numberFormatValues.find((item) => item.value === numberFormat)?.regex;
if (foundRegex) {
return z.string().refine(
(value) => {
return foundRegex.test(value.toString());
},
{
message: t`Number needs to be formatted as ${numberFormat}`,
},
);
}
}
// Not gong to work with min/max numbers + number format
// Since currently doesn't work in V1 going to ignore for now.
return z.string().superRefine((value, ctx) => {
const isValidNumber = /^[0-9,.]+$/.test(value.toString());
if (!isValidNumber) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: t`Please enter a valid number`,
});
return;
}
if (typeof minValue === 'number' && parseFloat(value) < minValue) {
ctx.addIssue({
code: z.ZodIssueCode.too_small,
minimum: minValue,
inclusive: true,
type: 'number',
});
return;
}
if (typeof maxValue === 'number' && parseFloat(value) > maxValue) {
ctx.addIssue({
code: z.ZodIssueCode.too_big,
maximum: maxValue,
inclusive: true,
type: 'number',
});
return;
}
});
};
const ZSignFieldNumberFormSchema = z.object({
number: createNumberFieldSchema(fieldMeta),
});

View File

@ -96,7 +96,7 @@ export const TemplateCreateDialog = ({ folderId }: TemplateCreateDialogProps) =>
<DialogTrigger asChild>
<Button className="cursor-pointer" disabled={!user.emailVerified}>
<FilePlus className="-ml-1 mr-2 h-4 w-4" />
<Trans>New Template</Trans>
<Trans>Template (Legacy)</Trans>
</Button>
</DialogTrigger>

View File

@ -265,7 +265,7 @@ export const TemplateDirectLinkDialog = ({
{remaining.directTemplates !== 0 && (
<DialogFooter className="mx-auto mt-4">
<Button type="button" onClick={() => setCurrentStep('SELECT_RECIPIENT')}>
<Trans> Enable direct link signing</Trans>
<Trans>Enable direct link signing</Trans>
</Button>
</DialogFooter>
)}

View File

@ -336,7 +336,7 @@ export const EmbedDirectTemplateClientPage = ({
<div className="flex-1">
<PDFViewer
envelopeItem={envelopeItems[0]}
token={token}
token={recipient.token}
version="signed"
onDocumentLoad={() => setHasDocumentLoaded(true)}
/>

View File

@ -0,0 +1,49 @@
import { useTransition } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import type { DateRange } from '@documenso/lib/types/search-params';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
type DateRangeFilterProps = {
currentRange: DateRange;
};
export const DateRangeFilter = ({ currentRange }: DateRangeFilterProps) => {
const { _ } = useLingui();
const [isPending, startTransition] = useTransition();
const updateSearchParams = useUpdateSearchParams();
const handleRangeChange = (value: string) => {
startTransition(() => {
updateSearchParams({
dateRange: value as DateRange,
page: 1,
});
});
};
return (
<div className="flex items-center gap-2">
<Select value={currentRange} onValueChange={handleRangeChange} disabled={isPending}>
<SelectTrigger className="w-48">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="last30days">{_(msg`Last 30 Days`)}</SelectItem>
<SelectItem value="last90days">{_(msg`Last 90 Days`)}</SelectItem>
<SelectItem value="lastYear">{_(msg`Last Year`)}</SelectItem>
<SelectItem value="allTime">{_(msg`All Time`)}</SelectItem>
</SelectContent>
</Select>
</div>
);
};

View File

@ -7,6 +7,7 @@ import type { z } from 'zod';
import {
DEFAULT_FIELD_FONT_SIZE,
type TDateFieldMeta as DateFieldMeta,
FIELD_DEFAULT_GENERIC_ALIGN,
ZDateFieldMeta,
} from '@documenso/lib/types/field-meta';
import { Form } from '@documenso/ui/primitives/form/form';
@ -39,7 +40,7 @@ export const EditorFieldDateForm = ({
mode: 'onChange',
defaultValues: {
fontSize: value.fontSize || DEFAULT_FIELD_FONT_SIZE,
textAlign: value.textAlign || 'left',
textAlign: value.textAlign ?? FIELD_DEFAULT_GENERIC_ALIGN,
},
});

View File

@ -7,6 +7,7 @@ import type { z } from 'zod';
import {
DEFAULT_FIELD_FONT_SIZE,
type TEmailFieldMeta as EmailFieldMeta,
FIELD_DEFAULT_GENERIC_ALIGN,
ZEmailFieldMeta,
} from '@documenso/lib/types/field-meta';
import { Form } from '@documenso/ui/primitives/form/form';
@ -39,7 +40,7 @@ export const EditorFieldEmailForm = ({
mode: 'onChange',
defaultValues: {
fontSize: value.fontSize || DEFAULT_FIELD_FONT_SIZE,
textAlign: value.textAlign || 'left',
textAlign: value.textAlign ?? FIELD_DEFAULT_GENERIC_ALIGN,
},
});

View File

@ -3,6 +3,10 @@ import { useEffect } from 'react';
import { Trans, useLingui } from '@lingui/react/macro';
import { type Control, useFormContext } from 'react-hook-form';
import { FIELD_MIN_LINE_HEIGHT } from '@documenso/lib/types/field-meta';
import { FIELD_MAX_LINE_HEIGHT } from '@documenso/lib/types/field-meta';
import { FIELD_MIN_LETTER_SPACING } from '@documenso/lib/types/field-meta';
import { FIELD_MAX_LETTER_SPACING } from '@documenso/lib/types/field-meta';
import { cn } from '@documenso/ui/lib/utils';
import { Checkbox } from '@documenso/ui/primitives/checkbox';
import {
@ -107,6 +111,119 @@ export const EditorGenericTextAlignField = ({
);
};
export const EditorGenericVerticalAlignField = ({
formControl,
className,
}: {
formControl: FormControlType;
className?: string;
}) => {
const { t } = useLingui();
return (
<FormField
control={formControl}
name="verticalAlign"
render={({ field }) => (
<FormItem className={className}>
<FormLabel>
<Trans>Vertical Align</Trans>
</FormLabel>
<FormControl>
<Select {...field} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder={t`Select vertical align`} />
</SelectTrigger>
<SelectContent>
<SelectItem value="top">
<Trans>Top</Trans>
</SelectItem>
<SelectItem value="middle">
<Trans>Middle</Trans>
</SelectItem>
<SelectItem value="bottom">
<Trans>Bottom</Trans>
</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
);
};
export const EditorGenericLineHeightField = ({
formControl,
className,
}: {
formControl: FormControlType;
className?: string;
}) => {
const { t } = useLingui();
return (
<FormField
control={formControl}
name="lineHeight"
render={({ field }) => (
<FormItem className={className}>
<FormLabel>
<Trans>Line Height</Trans>
</FormLabel>
<FormControl>
<Input
type="number"
min={FIELD_MIN_LINE_HEIGHT}
max={FIELD_MAX_LINE_HEIGHT}
className="bg-background"
placeholder={t`Line height`}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
);
};
export const EditorGenericLetterSpacingField = ({
formControl,
className,
}: {
formControl: FormControlType;
className?: string;
}) => {
const { t } = useLingui();
return (
<FormField
control={formControl}
name="letterSpacing"
render={({ field }) => (
<FormItem className={className}>
<FormLabel>
<Trans>Letter Spacing</Trans>
</FormLabel>
<FormControl>
<Input
type="number"
min={FIELD_MIN_LETTER_SPACING}
max={FIELD_MAX_LETTER_SPACING}
className="bg-background"
placeholder={t`Letter spacing`}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
);
};
export const EditorGenericRequiredField = ({
formControl,
className,

View File

@ -6,6 +6,7 @@ import type { z } from 'zod';
import {
DEFAULT_FIELD_FONT_SIZE,
FIELD_DEFAULT_GENERIC_ALIGN,
type TInitialsFieldMeta as InitialsFieldMeta,
ZInitialsFieldMeta,
} from '@documenso/lib/types/field-meta';
@ -39,7 +40,7 @@ export const EditorFieldInitialsForm = ({
mode: 'onChange',
defaultValues: {
fontSize: value.fontSize || DEFAULT_FIELD_FONT_SIZE,
textAlign: value.textAlign || 'left',
textAlign: value.textAlign ?? FIELD_DEFAULT_GENERIC_ALIGN,
},
});

View File

@ -6,6 +6,7 @@ import type { z } from 'zod';
import {
DEFAULT_FIELD_FONT_SIZE,
FIELD_DEFAULT_GENERIC_ALIGN,
type TNameFieldMeta as NameFieldMeta,
ZNameFieldMeta,
} from '@documenso/lib/types/field-meta';
@ -39,7 +40,7 @@ export const EditorFieldNameForm = ({
mode: 'onChange',
defaultValues: {
fontSize: value.fontSize || DEFAULT_FIELD_FONT_SIZE,
textAlign: value.textAlign || 'left',
textAlign: value.textAlign ?? FIELD_DEFAULT_GENERIC_ALIGN,
},
});

View File

@ -6,6 +6,11 @@ import { useForm, useWatch } from 'react-hook-form';
import type { z } from 'zod';
import {
DEFAULT_FIELD_FONT_SIZE,
FIELD_DEFAULT_GENERIC_ALIGN,
FIELD_DEFAULT_GENERIC_VERTICAL_ALIGN,
FIELD_DEFAULT_LETTER_SPACING,
FIELD_DEFAULT_LINE_HEIGHT,
type TNumberFieldMeta as NumberFieldMeta,
ZNumberFieldMeta,
} from '@documenso/lib/types/field-meta';
@ -31,9 +36,12 @@ import { Separator } from '@documenso/ui/primitives/separator';
import {
EditorGenericFontSizeField,
EditorGenericLabelField,
EditorGenericLetterSpacingField,
EditorGenericLineHeightField,
EditorGenericReadOnlyField,
EditorGenericRequiredField,
EditorGenericTextAlignField,
EditorGenericVerticalAlignField,
} from './editor-field-generic-field-forms';
const ZNumberFieldFormSchema = ZNumberFieldMeta.pick({
@ -43,6 +51,9 @@ const ZNumberFieldFormSchema = ZNumberFieldMeta.pick({
numberFormat: true,
fontSize: true,
textAlign: true,
lineHeight: true,
letterSpacing: true,
verticalAlign: true,
required: true,
readOnly: true,
minValue: true,
@ -99,8 +110,11 @@ export const EditorFieldNumberForm = ({
placeholder: value.placeholder || '',
value: value.value || '',
numberFormat: value.numberFormat || null,
fontSize: value.fontSize || 14,
textAlign: value.textAlign || 'left',
fontSize: value.fontSize || DEFAULT_FIELD_FONT_SIZE,
textAlign: value.textAlign ?? FIELD_DEFAULT_GENERIC_ALIGN,
lineHeight: value.lineHeight ?? FIELD_DEFAULT_LINE_HEIGHT,
letterSpacing: value.letterSpacing ?? FIELD_DEFAULT_LETTER_SPACING,
verticalAlign: value.verticalAlign ?? FIELD_DEFAULT_GENERIC_VERTICAL_ALIGN,
required: value.required || false,
readOnly: value.readOnly || false,
minValue: value.minValue,
@ -118,6 +132,10 @@ export const EditorFieldNumberForm = ({
useEffect(() => {
const validatedFormValues = ZNumberFieldFormSchema.safeParse(formValues);
if (formValues.readOnly && !formValues.value) {
void form.trigger('value');
}
if (validatedFormValues.success) {
onValueChange({
type: 'number',
@ -130,10 +148,12 @@ export const EditorFieldNumberForm = ({
<Form {...form}>
<form>
<fieldset className="flex flex-col gap-2">
<div className="flex w-full flex-row gap-x-4">
<EditorGenericFontSizeField className="w-full" formControl={form.control} />
<EditorGenericFontSizeField className="w-full" formControl={form.control} />
<div className="flex w-full flex-row gap-x-4">
<EditorGenericTextAlignField className="w-full" formControl={form.control} />
<EditorGenericVerticalAlignField className="w-full" formControl={form.control} />
</div>
<EditorGenericLabelField formControl={form.control} />
@ -204,6 +224,12 @@ export const EditorFieldNumberForm = ({
)}
/>
<div className="flex w-full flex-row gap-x-4">
<EditorGenericLineHeightField className="w-full" formControl={form.control} />
<EditorGenericLetterSpacingField className="w-full" formControl={form.control} />
</div>
<div className="mt-1">
<EditorGenericRequiredField formControl={form.control} />
</div>

View File

@ -5,11 +5,8 @@ import { Trans } from '@lingui/react/macro';
import { useForm, useWatch } from 'react-hook-form';
import type { z } from 'zod';
import {
DEFAULT_FIELD_FONT_SIZE,
type TSignatureFieldMeta,
ZSignatureFieldMeta,
} from '@documenso/lib/types/field-meta';
import { DEFAULT_SIGNATURE_TEXT_FONT_SIZE } from '@documenso/lib/constants/pdf';
import { type TSignatureFieldMeta, ZSignatureFieldMeta } from '@documenso/lib/types/field-meta';
import { Form } from '@documenso/ui/primitives/form/form';
import { EditorGenericFontSizeField } from './editor-field-generic-field-forms';
@ -35,7 +32,7 @@ export const EditorFieldSignatureForm = ({
resolver: zodResolver(ZSignatureFieldFormSchema),
mode: 'onChange',
defaultValues: {
fontSize: value.fontSize || DEFAULT_FIELD_FONT_SIZE,
fontSize: value.fontSize || DEFAULT_SIGNATURE_TEXT_FONT_SIZE,
},
});

View File

@ -3,11 +3,16 @@ import { useEffect } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, useLingui } from '@lingui/react/macro';
import { useForm, useWatch } from 'react-hook-form';
import { z } from 'zod';
import type { z } from 'zod';
import {
DEFAULT_FIELD_FONT_SIZE,
FIELD_DEFAULT_GENERIC_ALIGN,
FIELD_DEFAULT_GENERIC_VERTICAL_ALIGN,
FIELD_DEFAULT_LETTER_SPACING,
FIELD_DEFAULT_LINE_HEIGHT,
type TTextFieldMeta as TextFieldMeta,
ZTextFieldMeta,
} from '@documenso/lib/types/field-meta';
import {
Form,
@ -22,32 +27,36 @@ import { Textarea } from '@documenso/ui/primitives/textarea';
import {
EditorGenericFontSizeField,
EditorGenericLetterSpacingField,
EditorGenericLineHeightField,
EditorGenericReadOnlyField,
EditorGenericRequiredField,
EditorGenericTextAlignField,
EditorGenericVerticalAlignField,
} from './editor-field-generic-field-forms';
const ZTextFieldFormSchema = z
.object({
label: z.string().optional(),
placeholder: z.string().optional(),
text: z.string().optional(),
characterLimit: z.coerce.number().min(0).optional(),
fontSize: z.coerce.number().min(8).max(96).optional(),
textAlign: z.enum(['left', 'center', 'right']).optional(),
required: z.boolean().optional(),
readOnly: z.boolean().optional(),
})
.refine(
(data) => {
// A read-only field must have text
return !data.readOnly || (data.text && data.text.length > 0);
},
{
message: 'A read-only field must have text',
path: ['text'],
},
);
const ZTextFieldFormSchema = ZTextFieldMeta.pick({
label: true,
placeholder: true,
text: true,
characterLimit: true,
fontSize: true,
textAlign: true,
lineHeight: true,
letterSpacing: true,
verticalAlign: true,
required: true,
readOnly: true,
}).refine(
(data) => {
// A read-only field must have text
return !data.readOnly || (data.text && data.text.length > 0);
},
{
message: 'A read-only field must have text',
path: ['text'],
},
);
type TTextFieldFormSchema = z.infer<typeof ZTextFieldFormSchema>;
@ -73,7 +82,10 @@ export const EditorFieldTextForm = ({
text: value.text || '',
characterLimit: value.characterLimit || 0,
fontSize: value.fontSize || DEFAULT_FIELD_FONT_SIZE,
textAlign: value.textAlign || 'left',
textAlign: value.textAlign ?? FIELD_DEFAULT_GENERIC_ALIGN,
lineHeight: value.lineHeight ?? FIELD_DEFAULT_LINE_HEIGHT,
letterSpacing: value.letterSpacing ?? FIELD_DEFAULT_LETTER_SPACING,
verticalAlign: value.verticalAlign ?? FIELD_DEFAULT_GENERIC_VERTICAL_ALIGN,
required: value.required || false,
readOnly: value.readOnly || false,
},
@ -89,6 +101,10 @@ export const EditorFieldTextForm = ({
useEffect(() => {
const validatedFormValues = ZTextFieldFormSchema.safeParse(formValues);
if (formValues.readOnly && !formValues.text) {
void form.trigger('text');
}
if (validatedFormValues.success) {
onValueChange({
type: 'text',
@ -101,10 +117,12 @@ export const EditorFieldTextForm = ({
<Form {...form}>
<form>
<fieldset className="flex flex-col gap-2">
<div className="flex w-full flex-row gap-x-4">
<EditorGenericFontSizeField className="w-full" formControl={form.control} />
<EditorGenericFontSizeField className="w-full" formControl={form.control} />
<div className="flex w-full flex-row gap-x-4">
<EditorGenericTextAlignField className="w-full" formControl={form.control} />
<EditorGenericVerticalAlignField className="w-full" formControl={form.control} />
</div>
<FormField
@ -182,17 +200,16 @@ export const EditorFieldTextForm = ({
</FormLabel>
<FormControl>
<Input
type="number"
min={0}
className="bg-background"
placeholder={t`Field character limit`}
placeholder={t`Character limit`}
{...field}
value={field.value || ''}
onChange={(e) => {
field.onChange(e);
const values = form.getValues();
const characterLimit = parseInt(e.target.value, 10) || 0;
field.onChange(characterLimit || '');
const textValue = values.text || '';
if (characterLimit > 0 && textValue.length > characterLimit) {
@ -206,6 +223,12 @@ export const EditorFieldTextForm = ({
)}
/>
<div className="flex w-full flex-row gap-x-4">
<EditorGenericLineHeightField className="w-full" formControl={form.control} />
<EditorGenericLetterSpacingField className="w-full" formControl={form.control} />
</div>
<div className="mt-1">
<EditorGenericRequiredField formControl={form.control} />
</div>

View File

@ -184,10 +184,10 @@ export const DocumentSigningPageViewV2 = () => {
</div>
)}
<div className="embed--DocumentWidgetFooter">
<div className="embed--DocumentWidgetFooter mt-auto">
{/* Footer of left sidebar. */}
{!isEmbed && (
<div className="mt-auto px-4">
<div className="px-4">
<Button asChild variant="ghost" className="w-full justify-start">
<Link to="/">
<ArrowLeftIcon className="mr-2 h-4 w-4" />

View File

@ -1,7 +1,7 @@
import { useEffect, useState } from 'react';
import { Trans } from '@lingui/react/macro';
import { type DocumentData, DocumentStatus, type EnvelopeItem } from '@prisma/client';
import { type DocumentData, DocumentStatus, type EnvelopeItem, EnvelopeType } from '@prisma/client';
import { DownloadIcon } from 'lucide-react';
import { DateTime } from 'luxon';
@ -100,7 +100,14 @@ export const DocumentCertificateQRView = ({
)}
{internalVersion === 2 ? (
<EnvelopeRenderProvider envelope={{ envelopeItems }} token={token}>
<EnvelopeRenderProvider
envelope={{
envelopeItems,
status: DocumentStatus.COMPLETED,
type: EnvelopeType.DOCUMENT,
}}
token={token}
>
<DocumentCertificateQrV2
title={title}
recipientCount={recipientCount}
@ -130,7 +137,7 @@ export const DocumentCertificateQRView = ({
envelopeItems={envelopeItems}
token={token}
trigger={
<Button type="button" variant="outline" className="flex-1">
<Button type="button" variant="outline" className="w-fit">
<DownloadIcon className="mr-2 h-5 w-5" />
<Trans>Download</Trans>
</Button>
@ -189,7 +196,7 @@ const DocumentCertificateQrV2 = ({
envelopeItems={envelopeItems}
token={token}
trigger={
<Button type="button" variant="outline" className="flex-1">
<Button type="button" variant="outline" className="w-fit">
<DownloadIcon className="mr-2 h-5 w-5" />
<Trans>Download</Trans>
</Button>

View File

@ -7,6 +7,7 @@ import { DateTime } from 'luxon';
import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted';
import type { TEnvelope } from '@documenso/lib/types/envelope';
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
export type DocumentPageViewInformationProps = {
userId: number;
@ -40,6 +41,10 @@ export const DocumentPageViewInformation = ({
.setLocale(i18n.locales?.[0] || i18n.locale)
.toRelative(),
},
{
description: msg`Document ID (Legacy)`,
value: mapSecondaryIdToDocumentId(envelope.secondaryId),
},
];
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isMounted, envelope, userId]);

View File

@ -3,6 +3,7 @@ import { useMemo, useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { EnvelopeType } from '@prisma/client';
import { useNavigate, useParams } from 'react-router';
import { match } from 'ts-pattern';
@ -17,7 +18,7 @@ import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import type { TCreateDocumentPayloadSchema } from '@documenso/trpc/server/document-router/create-document.types';
import { cn } from '@documenso/ui/lib/utils';
import { DocumentDropzone } from '@documenso/ui/primitives/document-upload';
import { DocumentUploadButton as DocumentUploadButtonPrimitive } from '@documenso/ui/primitives/document-upload-button';
import {
Tooltip,
TooltipContent,
@ -28,11 +29,11 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
import { useCurrentTeam } from '~/providers/team';
export type DocumentUploadButtonProps = {
export type DocumentUploadButtonLegacyProps = {
className?: string;
};
export const DocumentUploadButton = ({ className }: DocumentUploadButtonProps) => {
export const DocumentUploadButtonLegacy = ({ className }: DocumentUploadButtonLegacyProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const { user } = useSession();
@ -75,8 +76,10 @@ export const DocumentUploadButton = ({ className }: DocumentUploadButtonProps) =
const payload = {
title: file.name,
timezone: userTimezone,
folderId: folderId ?? undefined,
meta: {
timezone: userTimezone,
},
} satisfies TCreateDocumentPayloadSchema;
const formData = new FormData();
@ -144,12 +147,14 @@ export const DocumentUploadButton = ({ className }: DocumentUploadButtonProps) =
<Tooltip>
<TooltipTrigger asChild>
<div>
<DocumentDropzone
<DocumentUploadButtonPrimitive
loading={isLoading}
disabled={remaining.documents === 0 || !user.emailVerified}
disabledMessage={disabledMessage}
onDrop={async (files) => onFileDrop(files[0])}
onDropRejected={onFileDropRejected}
type={EnvelopeType.DOCUMENT}
internalVersion="1"
/>
</div>
</TooltipTrigger>

View File

@ -11,6 +11,10 @@ import type { TLocalField } from '@documenso/lib/client-only/hooks/use-editor-fi
import { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-renderer';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
import {
registerPageCanvas,
unregisterPageCanvas,
} from '@documenso/lib/client-only/utils/page-canvas-registry';
import { FIELD_META_DEFAULT_VALUES } from '@documenso/lib/types/field-meta';
import {
MIN_FIELD_HEIGHT_PX,
@ -56,6 +60,15 @@ export default function EnvelopeEditorFieldsPageRenderer() {
[editorFields.localFields, pageContext.pageNumber],
);
/**
* Cleanup: Unregister canvas when component unmounts
*/
useEffect(() => {
return () => {
unregisterPageCanvas(pageContext.pageNumber);
};
}, [pageContext.pageNumber]);
const handleResizeOrMove = (event: KonvaEventObject<Event>) => {
const { current: container } = canvasElement;
@ -222,6 +235,15 @@ export default function EnvelopeEditorFieldsPageRenderer() {
currentStage.on('transformend', () => setIsFieldChanging(false));
currentPageLayer.batchDraw();
// Register this page's canvas references now that everything is initialized
if (canvasElement.current && currentStage) {
registerPageCanvas({
pageNumber: pageContext.pageNumber,
pdfCanvas: canvasElement.current,
konvaStage: currentStage,
});
}
};
/**
@ -616,13 +638,14 @@ export default function EnvelopeEditorFieldsPageRenderer() {
transform: 'translateX(-50%)',
zIndex: 50,
}}
className="text-muted-foreground grid w-max grid-cols-5 gap-x-1 gap-y-0.5 rounded-md border bg-white p-1 shadow-sm"
// Don't use darkmode for this component, it should look the same for both light/dark modes.
className="grid w-max grid-cols-5 gap-x-1 gap-y-0.5 rounded-md border border-gray-300 bg-white p-1 text-gray-500 shadow-sm"
>
{fieldButtonList.map((field) => (
<button
key={field.type}
onClick={() => createFieldFromPendingTemplate(pendingFieldCreation, field.type)}
className="hover:text-foreground col-span-1 w-full flex-shrink-0 rounded-sm px-2 py-1 text-xs hover:bg-gray-100"
className="col-span-1 w-full flex-shrink-0 rounded-sm px-2 py-1 text-xs hover:bg-gray-100 hover:text-gray-600"
>
{t(field.name)}
</button>

View File

@ -1,4 +1,4 @@
import { lazy, useEffect, useMemo } from 'react';
import { lazy, useEffect, useMemo, useState } from 'react';
import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
@ -11,6 +11,8 @@ import { match } from 'ts-pattern';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
import { getPageCanvasRefs } from '@documenso/lib/client-only/utils/page-canvas-registry';
import type { TDetectedFormField } from '@documenso/lib/types/ai';
import type {
TCheckboxFieldMeta,
TDateFieldMeta,
@ -24,6 +26,7 @@ import type {
TSignatureFieldMeta,
TTextFieldMeta,
} from '@documenso/lib/types/field-meta';
import { FIELD_META_DEFAULT_VALUES } from '@documenso/lib/types/field-meta';
import { canRecipientFieldsBeModified } from '@documenso/lib/utils/recipients';
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
import PDFViewerKonvaLazy from '@documenso/ui/components/pdf-viewer/pdf-viewer-konva-lazy';
@ -31,6 +34,7 @@ import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/al
import { Button } from '@documenso/ui/primitives/button';
import { RecipientSelector } from '@documenso/ui/primitives/recipient-selector';
import { Separator } from '@documenso/ui/primitives/separator';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { EditorFieldCheckboxForm } from '~/components/forms/editor/editor-field-checkbox-form';
import { EditorFieldDateForm } from '~/components/forms/editor/editor-field-date-form';
@ -50,6 +54,136 @@ const EnvelopeEditorFieldsPageRenderer = lazy(
async () => import('./envelope-editor-fields-page-renderer'),
);
/**
* Enforces minimum field dimensions and centers the field when expanding to meet minimums.
*
* AI often detects form lines as very thin fields (0.2-0.5% height). This function ensures
* fields meet minimum usability requirements by expanding them to at least 30px height and
* 36px width, while keeping them centered on their original position.
*
* @param params - Field dimensions and page size
* @param params.positionX - Field X position as percentage (0-100)
* @param params.positionY - Field Y position as percentage (0-100)
* @param params.width - Field width as percentage (0-100)
* @param params.height - Field height as percentage (0-100)
* @param params.pageWidth - Page width in pixels
* @param params.pageHeight - Page height in pixels
* @returns Adjusted field dimensions with minimums enforced and centered
*
* @example
* // AI detected a thin line: 0.3% height
* const adjusted = enforceMinimumFieldDimensions({
* positionX: 20, positionY: 50, width: 30, height: 0.3,
* pageWidth: 800, pageHeight: 1100
* });
* // Result: height expanded to ~2.7% (30px), centered on original position
*/
const enforceMinimumFieldDimensions = (params: {
positionX: number;
positionY: number;
width: number;
height: number;
pageWidth: number;
pageHeight: number;
}): {
positionX: number;
positionY: number;
width: number;
height: number;
} => {
const MIN_HEIGHT_PX = 30;
const MIN_WIDTH_PX = 36;
// Convert percentage to pixels to check against minimums
const widthPx = (params.width / 100) * params.pageWidth;
const heightPx = (params.height / 100) * params.pageHeight;
let adjustedWidth = params.width;
let adjustedHeight = params.height;
let adjustedPositionX = params.positionX;
let adjustedPositionY = params.positionY;
if (widthPx < MIN_WIDTH_PX) {
const centerXPx = (params.positionX / 100) * params.pageWidth + widthPx / 2;
adjustedWidth = (MIN_WIDTH_PX / params.pageWidth) * 100;
adjustedPositionX = ((centerXPx - MIN_WIDTH_PX / 2) / params.pageWidth) * 100;
if (adjustedPositionX < 0) {
adjustedPositionX = 0;
} else if (adjustedPositionX + adjustedWidth > 100) {
adjustedPositionX = 100 - adjustedWidth;
}
}
if (heightPx < MIN_HEIGHT_PX) {
const centerYPx = (params.positionY / 100) * params.pageHeight + heightPx / 2;
adjustedHeight = (MIN_HEIGHT_PX / params.pageHeight) * 100;
adjustedPositionY = ((centerYPx - MIN_HEIGHT_PX / 2) / params.pageHeight) * 100;
if (adjustedPositionY < 0) {
adjustedPositionY = 0;
} else if (adjustedPositionY + adjustedHeight > 100) {
adjustedPositionY = 100 - adjustedHeight;
}
}
return {
positionX: adjustedPositionX,
positionY: adjustedPositionY,
width: adjustedWidth,
height: adjustedHeight,
};
};
const processAllPagesWithAI = async (params: {
documentDataId: string;
onProgress: (current: number, total: number) => void;
}): Promise<{
fieldsPerPage: Map<number, TDetectedFormField[]>;
errors: Map<number, Error>;
}> => {
const { documentDataId, onProgress } = params;
const fieldsPerPage = new Map<number, TDetectedFormField[]>();
const errors = new Map<number, Error>();
try {
// Make single API call to process all pages server-side
onProgress(0, 1);
const response = await fetch('/api/ai/detect-form-fields', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ documentId: documentDataId }),
credentials: 'include',
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`AI detection failed: ${response.statusText} - ${errorText}`);
}
const detectedFields: TDetectedFormField[] = await response.json();
// Group fields by page number
for (const field of detectedFields) {
if (!fieldsPerPage.has(field.pageNumber)) {
fieldsPerPage.set(field.pageNumber, []);
}
fieldsPerPage.get(field.pageNumber)!.push(field);
}
onProgress(1, 1);
} catch (error) {
// If request fails, treat it as error for all pages
errors.set(0, error instanceof Error ? error : new Error(String(error)));
}
return { fieldsPerPage, errors };
};
const FieldSettingsTypeTranslations: Record<FieldType, MessageDescriptor> = {
[FieldType.SIGNATURE]: msg`Signature Settings`,
[FieldType.FREE_SIGNATURE]: msg`Free Signature Settings`,
@ -70,6 +204,13 @@ export const EnvelopeEditorFieldsPage = () => {
const { currentEnvelopeItem } = useCurrentEnvelopeRender();
const { t } = useLingui();
const { toast } = useToast();
const [isAutoAddingFields, setIsAutoAddingFields] = useState(false);
const [processingProgress, setProcessingProgress] = useState<{
current: number;
total: number;
} | null>(null);
const selectedField = useMemo(
() => structuredClone(editorFields.selectedField),
@ -108,8 +249,17 @@ export const EnvelopeEditorFieldsPage = () => {
return (
<div className="relative flex h-full">
<div className="flex w-full flex-col overflow-y-auto">
<div className="relative flex w-full flex-col overflow-y-auto">
{/* Horizontal envelope item selector */}
{isAutoAddingFields && (
<>
<div className="edge-glow edge-glow-top pointer-events-none fixed left-0 right-0 top-0 z-20 h-16" />
<div className="edge-glow edge-glow-right pointer-events-none fixed bottom-0 right-0 top-0 z-20 w-16" />
<div className="edge-glow edge-glow-bottom pointer-events-none fixed bottom-0 left-0 right-0 z-20 h-16" />
<div className="edge-glow edge-glow-left pointer-events-none fixed bottom-0 left-0 top-0 z-20 w-16" />
</>
)}
<EnvelopeRendererFileSelector fields={editorFields.localFields} />
{/* Document View */}
@ -199,6 +349,141 @@ export const EnvelopeEditorFieldsPage = () => {
selectedRecipientId={editorFields.selectedRecipient?.id ?? null}
selectedEnvelopeItemId={currentEnvelopeItem?.id ?? null}
/>
<Button
className="mt-4 w-full"
variant="outline"
disabled={isAutoAddingFields}
onClick={async () => {
setIsAutoAddingFields(true);
setProcessingProgress(null);
try {
if (!editorFields.selectedRecipient || !currentEnvelopeItem) {
toast({
title: t`Warning`,
description: t`Please select a recipient before adding fields.`,
variant: 'destructive',
});
return;
}
if (!currentEnvelopeItem.documentDataId) {
toast({
title: t`Error`,
description: t`Document data not found. Please try reloading the page.`,
variant: 'destructive',
});
return;
}
const { fieldsPerPage, errors } = await processAllPagesWithAI({
documentDataId: currentEnvelopeItem.documentDataId,
onProgress: (current, total) => {
setProcessingProgress({ current, total });
},
});
let totalAdded = 0;
for (const [pageNumber, detectedFields] of fieldsPerPage.entries()) {
const pageCanvasRefs = getPageCanvasRefs(pageNumber);
for (const detected of detectedFields) {
const [ymin, xmin, ymax, xmax] = detected.boundingBox;
let positionX = (xmin / 1000) * 100;
let positionY = (ymin / 1000) * 100;
let width = ((xmax - xmin) / 1000) * 100;
let height = ((ymax - ymin) / 1000) * 100;
if (pageCanvasRefs) {
const adjusted = enforceMinimumFieldDimensions({
positionX,
positionY,
width,
height,
pageWidth: pageCanvasRefs.pdfCanvas.width,
pageHeight: pageCanvasRefs.pdfCanvas.height,
});
positionX = adjusted.positionX;
positionY = adjusted.positionY;
width = adjusted.width;
height = adjusted.height;
}
const fieldType = detected.label as FieldType;
try {
editorFields.addField({
envelopeItemId: currentEnvelopeItem.id,
page: pageNumber,
type: fieldType,
positionX,
positionY,
width,
height,
recipientId: editorFields.selectedRecipient.id,
fieldMeta: structuredClone(FIELD_META_DEFAULT_VALUES[fieldType]),
});
totalAdded++;
} catch (error) {
console.error(`Failed to add field on page ${pageNumber}:`, error);
}
}
}
const successfulPages = fieldsPerPage.size;
const failedPages = errors.size;
if (totalAdded > 0) {
let description = t`Added ${totalAdded} fields`;
if (fieldsPerPage.size > 1) {
description = t`Added ${totalAdded} fields across ${successfulPages} pages`;
}
if (failedPages > 0) {
description = t`Added ${totalAdded} fields across ${successfulPages} pages. ${failedPages} pages failed.`;
}
toast({
title: t`Success`,
description,
});
} else if (failedPages > 0) {
toast({
title: t`Error`,
description: t`Failed to detect fields on ${failedPages} pages. Please try again.`,
variant: 'destructive',
});
} else {
toast({
title: t`Info`,
description: t`No fields were detected in the document`,
});
}
} catch (error) {
toast({
title: t`Error`,
description: t`An unexpected error occurred while processing pages.`,
variant: 'destructive',
});
} finally {
setIsAutoAddingFields(false);
setProcessingProgress(null);
}
}}
>
{isAutoAddingFields ? (
processingProgress ? (
<Trans>
Processing page {processingProgress.current} of {processingProgress.total}...
</Trans>
) : (
<Trans>Processing...</Trans>
)
) : (
<Trans>Auto add fields</Trans>
)}
</Button>
</section>
{/* Field details section. */}

View File

@ -2,7 +2,7 @@ import { lazy, useEffect, useMemo, useState } from 'react';
import { faker } from '@faker-js/faker/locale/en';
import { Trans } from '@lingui/react/macro';
import { FieldType } from '@prisma/client';
import { FieldType, SigningStatus } from '@prisma/client';
import { FileTextIcon } from 'lucide-react';
import { match } from 'ts-pattern';
@ -201,7 +201,10 @@ export const EnvelopeEditorPreviewPage = () => {
envelope={envelope}
token={undefined}
fields={fieldsWithPlaceholders}
recipients={envelope.recipients}
recipients={envelope.recipients.map((recipient) => ({
...recipient,
signingStatus: SigningStatus.SIGNED,
}))}
overrideSettings={{
mode: 'export',
}}

View File

@ -212,7 +212,7 @@ export const EnvelopeEditorRecipientForm = () => {
);
const hasDocumentBeenSent = recipients.some(
(recipient) => recipient.sendStatus === SendStatus.SENT,
(recipient) => recipient.role !== RecipientRole.CC && recipient.sendStatus === SendStatus.SENT,
);
const canRecipientBeModified = (recipientId?: number) => {

View File

@ -1,12 +1,11 @@
import { useMemo, useState } from 'react';
import { DragDropContext, Draggable, Droppable } from '@hello-pangea/dnd';
import type { DropResult } from '@hello-pangea/dnd';
import { DragDropContext, Draggable, Droppable } from '@hello-pangea/dnd';
import { msg } from '@lingui/core/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import { DocumentStatus } from '@prisma/client';
import { FileWarningIcon, GripVerticalIcon, Loader2 } from 'lucide-react';
import { X } from 'lucide-react';
import { FileWarningIcon, GripVerticalIcon, Loader2, X } from 'lucide-react';
import { ErrorCode as DropzoneErrorCode, type FileRejection } from 'react-dropzone';
import { Link } from 'react-router';
@ -49,7 +48,7 @@ export const EnvelopeEditorUploadPage = () => {
const organisation = useCurrentOrganisation();
const { t } = useLingui();
const { envelope, setLocalEnvelope, relativePath } = useCurrentEnvelopeEditor();
const { envelope, setLocalEnvelope, relativePath, editorFields } = useCurrentEnvelopeEditor();
const { maximumEnvelopeItemCount, remaining } = useLimits();
const { toast } = useToast();
@ -165,9 +164,17 @@ export const EnvelopeEditorUploadPage = () => {
const onFileDelete = (envelopeItemId: string) => {
setLocalFiles((prev) => prev.filter((uploadingFile) => uploadingFile.id !== envelopeItemId));
const fieldsWithoutDeletedItem = envelope.fields.filter(
(field) => field.envelopeItemId !== envelopeItemId,
);
setLocalEnvelope({
envelopeItems: envelope.envelopeItems.filter((item) => item.id !== envelopeItemId),
fields: envelope.fields.filter((field) => field.envelopeItemId !== envelopeItemId),
});
// Reset editor fields.
editorFields.resetForm(fieldsWithoutDeletedItem);
};
/**

View File

@ -1,7 +1,7 @@
import { useEffect, useMemo } from 'react';
import { useLingui } from '@lingui/react/macro';
import { type Recipient, SigningStatus } from '@prisma/client';
import { DocumentStatus, type Recipient, SigningStatus } from '@prisma/client';
import type Konva from 'konva';
import { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-renderer';
@ -19,6 +19,7 @@ export default function EnvelopeGenericPageRenderer() {
const { i18n } = useLingui();
const {
envelopeStatus,
currentEnvelopeItem,
fields,
recipients,
@ -42,6 +43,10 @@ export default function EnvelopeGenericPageRenderer() {
const { _className, scale } = pageContext;
const localPageFields = useMemo((): GenericLocalField[] => {
if (envelopeStatus === DocumentStatus.COMPLETED) {
return [];
}
return fields
.filter(
(field) =>
@ -54,11 +59,20 @@ export default function EnvelopeGenericPageRenderer() {
throw new Error(`Recipient not found for field ${field.id}`);
}
const isInserted = recipient.signingStatus === SigningStatus.SIGNED && field.inserted;
return {
...field,
inserted: isInserted,
customText: isInserted ? field.customText : '',
recipient,
};
});
})
.filter(
({ inserted, fieldMeta, recipient }) =>
(recipient.signingStatus === SigningStatus.SIGNED ? inserted : true) ||
fieldMeta?.readOnly,
);
}, [fields, pageContext.pageNumber, currentEnvelopeItem?.id, recipients]);
const unsafeRenderFieldOnLayer = (field: GenericLocalField) => {
@ -67,12 +81,8 @@ export default function EnvelopeGenericPageRenderer() {
return;
}
const { recipient } = field;
const fieldTranslations = getClientSideFieldTranslations(i18n);
const isInserted = recipient.signingStatus === SigningStatus.SIGNED && field.inserted;
renderField({
scale,
pageLayer: pageLayer.current,
@ -83,7 +93,6 @@ export default function EnvelopeGenericPageRenderer() {
height: Number(field.height),
positionX: Number(field.positionX),
positionY: Number(field.positionY),
customText: isInserted ? field.customText : '',
fieldMeta: field.fieldMeta,
signature: {
signatureImageAsBase64: '',
@ -95,7 +104,7 @@ export default function EnvelopeGenericPageRenderer() {
pageHeight: unscaledViewport.height,
color: getRecipientColorKey(field.recipientId),
editable: false,
mode: overrideSettings?.mode ?? 'sign',
mode: overrideSettings?.mode ?? 'edit',
});
};

View File

@ -1,7 +1,14 @@
import { useEffect, useMemo } from 'react';
import { Trans, useLingui } from '@lingui/react/macro';
import { type Field, FieldType, RecipientRole, type Signature } from '@prisma/client';
import {
type Field,
FieldType,
type Recipient,
RecipientRole,
type Signature,
SigningStatus,
} from '@prisma/client';
import type Konva from 'konva';
import type { KonvaEventObject } from 'konva/lib/Node';
import { match } from 'ts-pattern';
@ -12,6 +19,7 @@ import { useOptionalSession } from '@documenso/lib/client-only/providers/session
import { DIRECT_TEMPLATE_RECIPIENT_EMAIL } from '@documenso/lib/constants/direct-templates';
import { isBase64Image } from '@documenso/lib/constants/signatures';
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import type { TEnvelope } from '@documenso/lib/types/envelope';
import { ZFullFieldSchema } from '@documenso/lib/types/field';
import { createSpinner } from '@documenso/lib/universal/field-renderer/field-generic-items';
import { renderField } from '@documenso/lib/universal/field-renderer/render-field';
@ -19,6 +27,7 @@ import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields
import { getClientSideFieldTranslations } from '@documenso/lib/utils/fields';
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
import type { TSignEnvelopeFieldValue } from '@documenso/trpc/server/envelope-router/sign-envelope-field.types';
import { EnvelopeRecipientFieldTooltip } from '@documenso/ui/components/document/envelope-recipient-field-tooltip';
import { EnvelopeFieldToolTip } from '@documenso/ui/components/field/envelope-field-tooltip';
import type { TRecipientColor } from '@documenso/ui/lib/recipient-colors';
import { useToast } from '@documenso/ui/primitives/use-toast';
@ -36,6 +45,10 @@ import { handleTextFieldClick } from '~/utils/field-signing/text-field';
import { useRequiredDocumentSigningAuthContext } from '../document-signing/document-signing-auth-provider';
import { useRequiredEnvelopeSigningContext } from '../document-signing/envelope-signing-provider';
type GenericLocalField = TEnvelope['fields'][number] & {
recipient: Pick<Recipient, 'id' | 'name' | 'email' | 'signingStatus'>;
};
export default function EnvelopeSignerPageRenderer() {
const { t, i18n } = useLingui();
const { currentEnvelopeItem, setRenderError } = useCurrentEnvelopeRender();
@ -91,6 +104,36 @@ export default function EnvelopeSignerPageRenderer() {
);
}, [recipientFields, selectedAssistantRecipientFields, pageContext.pageNumber]);
/**
* Returns fields that have been fully signed by other recipients for this specific
* page.
*/
const localPageOtherRecipientFields = useMemo((): GenericLocalField[] => {
const signedRecipients = envelope.recipients.filter(
(recipient) => recipient.signingStatus === SigningStatus.SIGNED,
);
return signedRecipients.flatMap((recipient) => {
return recipient.fields
.filter(
(field) =>
field.page === pageContext.pageNumber &&
field.envelopeItemId === currentEnvelopeItem?.id &&
(field.inserted || field.fieldMeta?.readOnly),
)
.map((field) => ({
...field,
recipient: {
id: recipient.id,
name: recipient.name,
email: recipient.email,
signingStatus: recipient.signingStatus,
role: recipient.role,
},
}));
});
}, [envelope.recipients, pageContext.pageNumber]);
const unsafeRenderFieldOnLayer = (unparsedField: Field & { signature?: Signature | null }) => {
if (!pageLayer.current) {
console.error('Layer not loaded yet');
@ -376,6 +419,46 @@ export default function EnvelopeSignerPageRenderer() {
}
};
const renderFields = () => {
if (!pageLayer.current) {
console.error('Layer not loaded yet');
return;
}
// Render current recipient fields.
for (const field of localPageFields) {
renderFieldOnLayer(field);
}
// Render other recipient signed and inserted fields.
for (const field of localPageOtherRecipientFields) {
try {
renderField({
scale,
pageLayer: pageLayer.current,
field: {
renderId: field.id.toString(),
...field,
width: Number(field.width),
height: Number(field.height),
positionX: Number(field.positionX),
positionY: Number(field.positionY),
fieldMeta: field.fieldMeta,
},
translations: getClientSideFieldTranslations(i18n),
pageWidth: unscaledViewport.width,
pageHeight: unscaledViewport.height,
color: 'readOnly',
editable: false,
mode: 'sign',
});
} catch (err) {
console.error('Unable to render one or more fields belonging to other recipients.');
console.error(err);
}
}
};
const signField = async (
fieldId: number,
payload: TSignEnvelopeFieldValue,
@ -412,11 +495,7 @@ export default function EnvelopeSignerPageRenderer() {
* Initialize the Konva page canvas and all fields and interactions.
*/
const createPageCanvas = (currentStage: Konva.Stage, currentPageLayer: Konva.Layer) => {
// Render the fields.
for (const field of localPageFields) {
renderFieldOnLayer(field);
}
renderFields();
currentPageLayer.batchDraw();
};
@ -428,9 +507,7 @@ export default function EnvelopeSignerPageRenderer() {
return;
}
localPageFields.forEach((field) => {
renderFieldOnLayer(field);
});
renderFields();
pageLayer.current.batchDraw();
}, [localPageFields, showPendingFieldTooltip, fullName, signature, email]);
@ -446,9 +523,7 @@ export default function EnvelopeSignerPageRenderer() {
// Rerender the whole page.
pageLayer.current.destroyChildren();
localPageFields.forEach((field) => {
renderFieldOnLayer(field);
});
renderFields();
pageLayer.current.batchDraw();
}, [selectedAssistantRecipient]);
@ -475,6 +550,15 @@ export default function EnvelopeSignerPageRenderer() {
</EnvelopeFieldToolTip>
)}
{localPageOtherRecipientFields.map((field) => (
<EnvelopeRecipientFieldTooltip
key={field.id}
field={field}
showFieldStatus={true}
showRecipientTooltip={true}
/>
))}
{/* The element Konva will inject it's canvas into. */}
<div className="konva-container absolute inset-0 z-10 w-full" ref={konvaContainer}></div>

View File

@ -75,14 +75,12 @@ export const EnvelopeSignerCompleteDialog = () => {
accessAuthOptions?: TRecipientAccessAuth,
) => {
try {
const payload = {
await completeDocument({
token: recipient.token,
documentId: mapSecondaryIdToDocumentId(envelope.secondaryId),
authOptions: accessAuthOptions,
accessAuthOptions,
...(nextSigner?.email && nextSigner?.name ? { nextSigner } : {}),
};
await completeDocument(payload);
});
analytics.capture('App: Recipient has completed signing', {
signerId: recipient.id,

View File

@ -1,10 +1,15 @@
import { type ReactNode, useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import { EnvelopeType } from '@prisma/client';
import { Loader } from 'lucide-react';
import { ErrorCode, type FileRejection, useDropzone } from 'react-dropzone';
import {
ErrorCode as DropzoneErrorCode,
ErrorCode,
type FileRejection,
useDropzone,
} from 'react-dropzone';
import { Link, useNavigate, useParams } from 'react-router';
import { match } from 'ts-pattern';
@ -16,21 +21,26 @@ import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT, IS_BILLING_ENABLED } from '@documenso/l
import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { megabytesToBytes } from '@documenso/lib/universal/unit-convertions';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import type { TCreateDocumentPayloadSchema } from '@documenso/trpc/server/document-router/create-document.types';
import type { TCreateEnvelopePayload } from '@documenso/trpc/server/envelope-router/create-envelope.types';
import { cn } from '@documenso/ui/lib/utils';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useCurrentTeam } from '~/providers/team';
export interface DocumentDropZoneWrapperProps {
export interface EnvelopeDropZoneWrapperProps {
children: ReactNode;
type: EnvelopeType;
className?: string;
}
export const DocumentDropZoneWrapper = ({ children, className }: DocumentDropZoneWrapperProps) => {
const { _ } = useLingui();
export const EnvelopeDropZoneWrapper = ({
children,
type,
className,
}: EnvelopeDropZoneWrapperProps) => {
const { t } = useLingui();
const { toast } = useToast();
const { user } = useSession();
const { folderId } = useParams();
@ -47,13 +57,13 @@ export const DocumentDropZoneWrapper = ({ children, className }: DocumentDropZon
TIME_ZONES.find((timezone) => timezone === Intl.DateTimeFormat().resolvedOptions().timeZone) ??
DEFAULT_DOCUMENT_TIME_ZONE;
const { quota, remaining, refreshLimits } = useLimits();
const { quota, remaining, refreshLimits, maximumEnvelopeItemCount } = useLimits();
const { mutateAsync: createDocument } = trpc.document.create.useMutation();
const { mutateAsync: createEnvelope } = trpc.envelope.create.useMutation();
const isUploadDisabled = remaining.documents === 0 || !user.emailVerified;
const onFileDrop = async (file: File) => {
const onFileDrop = async (files: File[]) => {
if (isUploadDisabled && IS_BILLING_ENABLED()) {
await navigate(`/o/${organisation.url}/settings/billing`);
return;
@ -63,51 +73,67 @@ export const DocumentDropZoneWrapper = ({ children, className }: DocumentDropZon
setIsLoading(true);
const payload = {
title: file.name,
timezone: userTimezone,
folderId: folderId ?? undefined,
} satisfies TCreateDocumentPayloadSchema;
folderId,
type,
title: files[0].name,
meta: {
timezone: userTimezone,
},
} satisfies TCreateEnvelopePayload;
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
formData.append('file', file);
const { envelopeId: id } = await createDocument(formData);
for (const file of files) {
formData.append('files', file);
}
const { id } = await createEnvelope(formData);
void refreshLimits();
toast({
title: _(msg`Document uploaded`),
description: _(msg`Your document has been uploaded successfully.`),
title: type === EnvelopeType.DOCUMENT ? t`Document uploaded` : t`Template uploaded`,
description:
type === EnvelopeType.DOCUMENT
? t`Your document has been uploaded successfully.`
: t`Your template has been uploaded successfully.`,
duration: 5000,
});
analytics.capture('App: Document Uploaded', {
userId: user.id,
documentId: id,
timestamp: new Date().toISOString(),
});
if (type === EnvelopeType.DOCUMENT) {
analytics.capture('App: Document Uploaded', {
userId: user.id,
documentId: id,
timestamp: new Date().toISOString(),
});
}
await navigate(`${formatDocumentsPath(team.url)}/${id}/edit`);
const pathPrefix =
type === EnvelopeType.DOCUMENT
? formatDocumentsPath(team.url)
: formatTemplatesPath(team.url);
await navigate(`${pathPrefix}/${id}/edit`);
} catch (err) {
const error = AppError.parseError(err);
const errorMessage = match(error.code)
.with('INVALID_DOCUMENT_FILE', () => msg`You cannot upload encrypted PDFs`)
.with('INVALID_DOCUMENT_FILE', () => t`You cannot upload encrypted PDFs`)
.with(
AppErrorCode.LIMIT_EXCEEDED,
() => msg`You have reached your document limit for this month. Please upgrade your plan.`,
() => t`You have reached your document limit for this month. Please upgrade your plan.`,
)
.with(
'ENVELOPE_ITEM_LIMIT_EXCEEDED',
() => msg`You have reached the limit of the number of files per envelope`,
() => t`You have reached the limit of the number of files per envelope`,
)
.otherwise(() => msg`An error occurred while uploading your document.`);
.otherwise(() => t`An error occurred during upload.`);
toast({
title: _(msg`Error`),
description: _(errorMessage),
title: t`Error`,
description: errorMessage,
variant: 'destructive',
duration: 7500,
});
@ -121,6 +147,20 @@ export const DocumentDropZoneWrapper = ({ children, className }: DocumentDropZon
return;
}
const maxItemsReached = fileRejections.some((fileRejection) =>
fileRejection.errors.some((error) => error.code === DropzoneErrorCode.TooManyFiles),
);
if (maxItemsReached) {
toast({
title: t`You cannot upload more than ${maximumEnvelopeItemCount} items per envelope.`,
duration: 5000,
variant: 'destructive',
});
return;
}
// Since users can only upload only one file (no multi-upload), we only handle the first file rejection
const { file, errors } = fileRejections[0];
@ -148,14 +188,14 @@ export const DocumentDropZoneWrapper = ({ children, className }: DocumentDropZon
const description = (
<>
<span className="font-medium">
{file.name} <Trans>couldn't be uploaded:</Trans>
<Trans>{file.name} couldn't be uploaded:</Trans>
</span>
{errorNodes}
</>
);
toast({
title: _(msg`Upload failed`),
title: t`Upload failed`,
description,
duration: 5000,
variant: 'destructive',
@ -165,17 +205,11 @@ export const DocumentDropZoneWrapper = ({ children, className }: DocumentDropZon
accept: {
'application/pdf': ['.pdf'],
},
//disabled: isUploadDisabled,
multiple: false,
multiple: true,
maxSize: megabytesToBytes(APP_DOCUMENT_UPLOAD_SIZE_LIMIT),
onDrop: ([acceptedFile]) => {
if (acceptedFile) {
void onFileDrop(acceptedFile);
}
},
onDropRejected: (fileRejections) => {
onFileDropRejected(fileRejections);
},
maxFiles: maximumEnvelopeItemCount,
onDrop: (files) => void onFileDrop(files),
onDropRejected: onFileDropRejected,
noClick: true,
noDragEventsBubbling: true,
});
@ -189,7 +223,11 @@ export const DocumentDropZoneWrapper = ({ children, className }: DocumentDropZon
<div className="bg-muted/60 fixed left-0 top-0 z-[9999] h-full w-full backdrop-blur-[4px]">
<div className="pointer-events-none flex h-full w-full flex-col items-center justify-center">
<h2 className="text-foreground text-2xl font-semibold">
<Trans>Upload Document</Trans>
{type === EnvelopeType.DOCUMENT ? (
<Trans>Upload Document</Trans>
) : (
<Trans>Upload Template</Trans>
)}
</h2>
<p className="text-muted-foreground text-md mt-4">
@ -224,7 +262,7 @@ export const DocumentDropZoneWrapper = ({ children, className }: DocumentDropZon
<div className="pointer-events-none flex h-1/2 w-full flex-col items-center justify-center">
<Loader className="text-primary h-12 w-12 animate-spin" />
<p className="text-foreground mt-8 font-medium">
<Trans>Uploading document...</Trans>
<Trans>Uploading</Trans>
</p>
</div>
</div>

View File

@ -18,7 +18,7 @@ import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/t
import { trpc } from '@documenso/trpc/react';
import type { TCreateEnvelopePayload } from '@documenso/trpc/server/envelope-router/create-envelope.types';
import { cn } from '@documenso/ui/lib/utils';
import { DocumentDropzone } from '@documenso/ui/primitives/document-upload';
import { DocumentUploadButton } from '@documenso/ui/primitives/document-upload-button';
import {
Tooltip,
TooltipContent,
@ -175,13 +175,14 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo
<Tooltip>
<TooltipTrigger asChild>
<div>
<DocumentDropzone
<DocumentUploadButton
loading={isLoading}
disabled={remaining.documents === 0 || !user.emailVerified}
disabledMessage={disabledMessage}
onDrop={onFileDrop}
onDropRejected={onFileDropRejected}
type="envelope"
type={type}
internalVersion="2"
maxFiles={maximumEnvelopeItemCount}
/>
</div>

View File

@ -6,7 +6,6 @@ import { FolderIcon, HomeIcon } from 'lucide-react';
import { Link } from 'react-router';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { IS_ENVELOPES_ENABLED } from '@documenso/lib/constants/app';
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import { type TFolderWithSubfolders } from '@documenso/trpc/server/folder-router/schema';
@ -17,11 +16,11 @@ import { FolderDeleteDialog } from '~/components/dialogs/folder-delete-dialog';
import { FolderMoveDialog } from '~/components/dialogs/folder-move-dialog';
import { FolderUpdateDialog } from '~/components/dialogs/folder-update-dialog';
import { TemplateCreateDialog } from '~/components/dialogs/template-create-dialog';
import { DocumentUploadButton } from '~/components/general/document/document-upload-button';
import { DocumentUploadButtonLegacy } from '~/components/general/document/document-upload-button-legacy';
import { FolderCard, FolderCardEmpty } from '~/components/general/folder/folder-card';
import { useCurrentTeam } from '~/providers/team';
import { EnvelopeUploadButton } from '../document/envelope-upload-button';
import { EnvelopeUploadButton } from '../envelope/envelope-upload-button';
export type FolderGridProps = {
type: FolderType;
@ -99,14 +98,12 @@ export const FolderGrid = ({ type, parentId }: FolderGridProps) => {
</div>
<div className="flex gap-4 sm:flex-row sm:justify-end">
{(IS_ENVELOPES_ENABLED || organisation.organisationClaim.flags.allowEnvelopes) && (
<EnvelopeUploadButton type={type} folderId={parentId || undefined} />
)}
<EnvelopeUploadButton type={type} folderId={parentId || undefined} />
{type === FolderType.DOCUMENT ? (
<DocumentUploadButton />
<DocumentUploadButtonLegacy /> // If you delete this, delete the component as well.
) : (
<TemplateCreateDialog folderId={parentId ?? undefined} />
<TemplateCreateDialog folderId={parentId ?? undefined} /> // If you delete this, delete the component as well.
)}
<FolderCreateDialog type={type} />

View File

@ -1,171 +0,0 @@
import { type ReactNode, useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { Loader } from 'lucide-react';
import { ErrorCode, type FileRejection, useDropzone } from 'react-dropzone';
import { useNavigate, useParams } from 'react-router';
import { match } from 'ts-pattern';
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
import { megabytesToBytes } from '@documenso/lib/universal/unit-convertions';
import { formatTemplatesPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import type { TCreateTemplatePayloadSchema } from '@documenso/trpc/server/template-router/schema';
import { cn } from '@documenso/ui/lib/utils';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useCurrentTeam } from '~/providers/team';
export interface TemplateDropZoneWrapperProps {
children: ReactNode;
className?: string;
}
export const TemplateDropZoneWrapper = ({ children, className }: TemplateDropZoneWrapperProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const { folderId } = useParams();
const team = useCurrentTeam();
const navigate = useNavigate();
const [isLoading, setIsLoading] = useState(false);
const { mutateAsync: createTemplate } = trpc.template.createTemplate.useMutation();
const onFileDrop = async (file: File) => {
try {
setIsLoading(true);
const payload = {
title: file.name,
folderId: folderId ?? undefined,
} satisfies TCreateTemplatePayloadSchema;
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
formData.append('file', file);
const { envelopeId: id } = await createTemplate(formData);
toast({
title: _(msg`Template uploaded`),
description: _(
msg`Your template has been uploaded successfully. You will be redirected to the template page.`,
),
duration: 5000,
});
await navigate(`${formatTemplatesPath(team.url)}/${id}/edit`);
} catch {
toast({
title: _(msg`Something went wrong`),
description: _(msg`Please try again later.`),
variant: 'destructive',
});
} finally {
setIsLoading(false);
}
};
const onFileDropRejected = (fileRejections: FileRejection[]) => {
if (!fileRejections.length) {
return;
}
// Since users can only upload only one file (no multi-upload), we only handle the first file rejection
const { file, errors } = fileRejections[0];
if (!errors.length) {
return;
}
const errorNodes = errors.map((error, index) => (
<span key={index} className="block">
{match(error.code)
.with(ErrorCode.FileTooLarge, () => (
<Trans>File is larger than {APP_DOCUMENT_UPLOAD_SIZE_LIMIT}MB</Trans>
))
.with(ErrorCode.FileInvalidType, () => <Trans>Only PDF files are allowed</Trans>)
.with(ErrorCode.FileTooSmall, () => <Trans>File is too small</Trans>)
.with(ErrorCode.TooManyFiles, () => (
<Trans>Only one file can be uploaded at a time</Trans>
))
.otherwise(() => (
<Trans>Unknown error</Trans>
))}
</span>
));
const description = (
<>
<span className="font-medium">
{file.name} <Trans>couldn't be uploaded:</Trans>
</span>
{errorNodes}
</>
);
toast({
title: _(msg`Upload failed`),
description,
duration: 5000,
variant: 'destructive',
});
};
const { getRootProps, getInputProps, isDragActive } = useDropzone({
accept: {
'application/pdf': ['.pdf'],
},
//disabled: isUploadDisabled,
multiple: false,
maxSize: megabytesToBytes(APP_DOCUMENT_UPLOAD_SIZE_LIMIT),
onDrop: ([acceptedFile]) => {
if (acceptedFile) {
void onFileDrop(acceptedFile);
}
},
onDropRejected: (fileRejections) => {
onFileDropRejected(fileRejections);
},
noClick: true,
noDragEventsBubbling: true,
});
return (
<div {...getRootProps()} className={cn('relative min-h-screen', className)}>
<input {...getInputProps()} />
{children}
{isDragActive && (
<div className="bg-muted/60 fixed left-0 top-0 z-[9999] h-full w-full backdrop-blur-[4px]">
<div className="pointer-events-none flex h-full w-full flex-col items-center justify-center">
<h2 className="text-foreground text-2xl font-semibold">
<Trans>Upload Template</Trans>
</h2>
<p className="text-muted-foreground text-md mt-4">
<Trans>Drag and drop your PDF file here</Trans>
</p>
</div>
</div>
)}
{isLoading && (
<div className="bg-muted/30 absolute inset-0 z-50 backdrop-blur-[2px]">
<div className="pointer-events-none flex h-1/2 w-full flex-col items-center justify-center">
<Loader className="text-primary h-12 w-12 animate-spin" />
<p className="text-foreground mt-8 font-medium">
<Trans>Uploading template...</Trans>
</p>
</div>
</div>
)}
</div>
);
};

View File

@ -7,11 +7,13 @@ import type { User } from '@prisma/client';
import { DateTime } from 'luxon';
import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted';
import { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope';
export type TemplatePageViewInformationProps = {
userId: number;
template: {
userId: number;
secondaryId: string;
createdAt: Date;
updatedAt: Date;
user: Pick<User, 'id' | 'name' | 'email'>;
@ -43,6 +45,10 @@ export const TemplatePageViewInformation = ({
.setLocale(i18n.locales?.[0] || i18n.locale)
.toRelative(),
},
{
description: msg`Template ID (Legacy)`,
value: mapSecondaryIdToTemplateId(template.secondaryId),
},
];
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isMounted, template, userId]);

View File

@ -2,40 +2,49 @@ import { useEffect, useMemo, useState, useTransition } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { ChevronDownIcon, ChevronUpIcon, ChevronsUpDown, Loader } from 'lucide-react';
import { Link } from 'react-router';
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import type { DateRange } from '@documenso/lib/types/search-params';
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
import { DataTable } from '@documenso/ui/primitives/data-table';
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
import { Input } from '@documenso/ui/primitives/input';
export type SigningVolume = {
id: number;
export type OrganisationOverview = {
id: string;
name: string;
signingVolume: number;
createdAt: Date;
planId: string;
customerId: string;
subscriptionStatus?: string;
isActive?: boolean;
teamCount?: number;
memberCount?: number;
};
type LeaderboardTableProps = {
signingVolume: SigningVolume[];
type OrganisationOverviewTableProps = {
organisations: OrganisationOverview[];
totalPages: number;
perPage: number;
page: number;
sortBy: 'name' | 'createdAt' | 'signingVolume';
sortOrder: 'asc' | 'desc';
dateRange: DateRange;
};
export const AdminLeaderboardTable = ({
signingVolume,
export const AdminOrganisationOverviewTable = ({
organisations,
totalPages,
perPage,
page,
sortBy,
sortOrder,
}: LeaderboardTableProps) => {
dateRange,
}: OrganisationOverviewTableProps) => {
const { _, i18n } = useLingui();
const [isPending, startTransition] = useTransition();
@ -67,17 +76,16 @@ export const AdminLeaderboardTable = ({
cell: ({ row }) => {
return (
<div>
<a
className="text-primary underline"
href={`https://dashboard.stripe.com/subscriptions/${row.original.planId}`}
target="_blank"
<Link
className="hover:underline"
to={`/admin/organisation-insights/${row.original.id}?dateRange=${dateRange}`}
>
{row.getValue('name')}
</a>
</Link>
</div>
);
},
size: 250,
size: 240,
},
{
header: () => (
@ -85,7 +93,9 @@ export const AdminLeaderboardTable = ({
className="flex cursor-pointer items-center"
onClick={() => handleColumnSort('signingVolume')}
>
{_(msg`Signing Volume`)}
<span className="whitespace-nowrap">
<Trans>Document Volume</Trans>
</span>
{sortBy === 'signingVolume' ? (
sortOrder === 'asc' ? (
<ChevronUpIcon className="ml-2 h-4 w-4" />
@ -99,6 +109,23 @@ export const AdminLeaderboardTable = ({
),
accessorKey: 'signingVolume',
cell: ({ row }) => <div>{Number(row.getValue('signingVolume'))}</div>,
size: 160,
},
{
header: () => {
return <Trans>Teams</Trans>;
},
accessorKey: 'teamCount',
cell: ({ row }) => <div>{Number(row.original.teamCount) || 0}</div>,
size: 120,
},
{
header: () => {
return <Trans>Members</Trans>;
},
accessorKey: 'memberCount',
cell: ({ row }) => <div>{Number(row.original.memberCount) || 0}</div>,
size: 160,
},
{
header: () => {
@ -107,7 +134,9 @@ export const AdminLeaderboardTable = ({
className="flex cursor-pointer items-center"
onClick={() => handleColumnSort('createdAt')}
>
{_(msg`Created`)}
<span className="whitespace-nowrap">
<Trans>Created</Trans>
</span>
{sortBy === 'createdAt' ? (
sortOrder === 'asc' ? (
<ChevronUpIcon className="ml-2 h-4 w-4" />
@ -121,10 +150,11 @@ export const AdminLeaderboardTable = ({
);
},
accessorKey: 'createdAt',
cell: ({ row }) => i18n.date(row.original.createdAt),
cell: ({ row }) => i18n.date(new Date(row.original.createdAt)),
size: 120,
},
] satisfies DataTableColumnDef<SigningVolume>[];
}, [sortOrder, sortBy]);
] satisfies DataTableColumnDef<OrganisationOverview>[];
}, [sortOrder, sortBy, dateRange]);
useEffect(() => {
startTransition(() => {
@ -169,13 +199,13 @@ export const AdminLeaderboardTable = ({
<Input
className="my-6 flex flex-row gap-4"
type="text"
placeholder={_(msg`Search by name or email`)}
placeholder={_(msg`Search by organisation name`)}
value={searchString}
onChange={handleChange}
/>
<DataTable
columns={columns}
data={signingVolume}
data={organisations}
perPage={perPage}
currentPage={page}
totalPages={totalPages}

View File

@ -93,13 +93,31 @@ export const AdminOrganisationsTable = ({
),
},
{
header: t`Status`,
id: 'role',
header: t`Role`,
cell: ({ row }) => (
<Badge variant="neutral">
{row.original.owner.id === memberUserId ? t`Owner` : t`Member`}
</Badge>
),
},
{
id: 'billingStatus',
header: t`Status`,
cell: ({ row }) => {
const subscription = row.original.subscription;
const isPaid = subscription && subscription.status === 'ACTIVE';
return (
<div
className={`inline-flex items-center rounded-full px-2 py-1 text-xs font-medium ${
isPaid ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'
}`}
>
{isPaid ? t`Paid` : t`Free`}
</div>
);
},
},
{
header: t`Subscription`,
cell: ({ row }) =>
@ -168,7 +186,7 @@ export const AdminOrganisationsTable = ({
onPaginationChange={onPaginationChange}
columnVisibility={{
owner: showOwnerColumn,
status: memberUserId !== undefined,
role: memberUserId !== undefined,
}}
error={{
enable: isLoadingError,

View File

@ -0,0 +1,287 @@
import { useTransition } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Building2, Loader, TrendingUp, Users } from 'lucide-react';
import { Link } from 'react-router';
import { useNavigation } from 'react-router';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import type { OrganisationDetailedInsights } from '@documenso/lib/server-only/admin/get-organisation-detailed-insights';
import type { DateRange } from '@documenso/lib/types/search-params';
import type { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
import { Button } from '@documenso/ui/primitives/button';
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
import { DataTable } from '@documenso/ui/primitives/data-table';
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
import { DateRangeFilter } from '~/components/filters/date-range-filter';
import { DocumentStatus } from '~/components/general/document/document-status';
type OrganisationInsightsTableProps = {
insights: OrganisationDetailedInsights;
page: number;
perPage: number;
dateRange: DateRange;
view: 'teams' | 'users' | 'documents';
};
export const OrganisationInsightsTable = ({
insights,
page,
perPage,
dateRange,
view,
}: OrganisationInsightsTableProps) => {
const { _, i18n } = useLingui();
const [isPending, startTransition] = useTransition();
const navigation = useNavigation();
const updateSearchParams = useUpdateSearchParams();
const isLoading = isPending || navigation.state === 'loading';
const onPaginationChange = (newPage: number, newPerPage: number) => {
startTransition(() => {
updateSearchParams({
page: newPage,
perPage: newPerPage,
});
});
};
const handleViewChange = (newView: 'teams' | 'users' | 'documents') => {
startTransition(() => {
updateSearchParams({
view: newView,
page: 1,
});
});
};
const teamsColumns = [
{
header: _(msg`Team Name`),
accessorKey: 'name',
cell: ({ row }) => <span className="block max-w-full truncate">{row.getValue('name')}</span>,
size: 240,
},
{
header: _(msg`Members`),
accessorKey: 'memberCount',
cell: ({ row }) => Number(row.getValue('memberCount')),
size: 120,
},
{
header: _(msg`Documents`),
accessorKey: 'documentCount',
cell: ({ row }) => Number(row.getValue('documentCount')),
size: 140,
},
{
header: _(msg`Created`),
accessorKey: 'createdAt',
cell: ({ row }) => i18n.date(new Date(row.getValue('createdAt'))),
size: 160,
},
] satisfies DataTableColumnDef<(typeof insights.teams)[number]>[];
const usersColumns = [
{
header: () => <span className="whitespace-nowrap">{_(msg`Name`)}</span>,
accessorKey: 'name',
cell: ({ row }) => (
<Link
className="block max-w-full truncate hover:underline"
to={`/admin/users/${row.original.id}`}
>
{(row.getValue('name') as string) || (row.getValue('email') as string)}
</Link>
),
size: 220,
},
{
header: () => <span className="whitespace-nowrap">{_(msg`Email`)}</span>,
accessorKey: 'email',
cell: ({ row }) => <span className="block max-w-full truncate">{row.getValue('email')}</span>,
size: 260,
},
{
header: () => <span className="whitespace-nowrap">{_(msg`Documents Created`)}</span>,
accessorKey: 'documentCount',
cell: ({ row }) => Number(row.getValue('documentCount')),
size: 180,
},
{
header: () => <span className="whitespace-nowrap">{_(msg`Documents Completed`)}</span>,
accessorKey: 'signedDocumentCount',
cell: ({ row }) => Number(row.getValue('signedDocumentCount')),
size: 180,
},
{
header: () => <span className="whitespace-nowrap">{_(msg`Joined`)}</span>,
accessorKey: 'createdAt',
cell: ({ row }) => i18n.date(new Date(row.getValue('createdAt'))),
size: 160,
},
] satisfies DataTableColumnDef<(typeof insights.users)[number]>[];
const documentsColumns = [
{
header: () => <span className="whitespace-nowrap">{_(msg`Title`)}</span>,
accessorKey: 'title',
cell: ({ row }) => (
<Link
className="block max-w-[200px] truncate hover:underline"
to={`/admin/documents/${row.original.id}`}
title={row.getValue('title') as string}
>
{row.getValue('title')}
</Link>
),
size: 200,
},
{
header: () => <span className="whitespace-nowrap">{_(msg`Status`)}</span>,
accessorKey: 'status',
cell: ({ row }) => (
<DocumentStatus status={row.getValue('status') as ExtendedDocumentStatus} />
),
size: 120,
},
{
header: () => <span className="whitespace-nowrap">{_(msg`Team`)}</span>,
accessorKey: 'teamName',
cell: ({ row }) => (
<span className="block max-w-[150px] truncate" title={row.getValue('teamName') as string}>
{row.getValue('teamName')}
</span>
),
size: 150,
},
{
header: () => <span className="whitespace-nowrap">{_(msg`Created`)}</span>,
accessorKey: 'createdAt',
cell: ({ row }) => i18n.date(new Date(row.getValue('createdAt'))),
size: 140,
},
{
header: () => <span className="whitespace-nowrap">{_(msg`Completed`)}</span>,
accessorKey: 'completedAt',
cell: ({ row }) => {
const completedAt = row.getValue('completedAt') as Date | null;
return completedAt ? i18n.date(new Date(completedAt)) : '-';
},
size: 140,
},
] satisfies DataTableColumnDef<(typeof insights.documents)[number]>[];
const getCurrentData = (): unknown[] => {
switch (view) {
case 'teams':
return insights.teams;
case 'users':
return insights.users;
case 'documents':
return insights.documents;
default:
return [];
}
};
const getCurrentColumns = (): DataTableColumnDef<unknown>[] => {
switch (view) {
case 'teams':
return teamsColumns as unknown as DataTableColumnDef<unknown>[];
case 'users':
return usersColumns as unknown as DataTableColumnDef<unknown>[];
case 'documents':
return documentsColumns as unknown as DataTableColumnDef<unknown>[];
default:
return [];
}
};
return (
<div className="relative">
{insights.summary && (
<div className="mb-6 grid grid-cols-2 gap-4 md:grid-cols-3">
<SummaryCard icon={Building2} title={_(msg`Teams`)} value={insights.summary.totalTeams} />
<SummaryCard icon={Users} title={_(msg`Members`)} value={insights.summary.totalMembers} />
<SummaryCard
icon={TrendingUp}
title={_(msg`Documents Completed`)}
value={insights.summary.volumeThisPeriod}
/>
</div>
)}
<div className="mb-6 flex items-center justify-between">
<div className="flex gap-2">
<Button
variant={view === 'teams' ? 'default' : 'outline'}
onClick={() => handleViewChange('teams')}
disabled={isLoading}
>
{_(msg`Teams`)}
</Button>
<Button
variant={view === 'users' ? 'default' : 'outline'}
onClick={() => handleViewChange('users')}
disabled={isLoading}
>
{_(msg`Users`)}
</Button>
<Button
variant={view === 'documents' ? 'default' : 'outline'}
onClick={() => handleViewChange('documents')}
disabled={isLoading}
>
{_(msg`Documents`)}
</Button>
</div>
<DateRangeFilter currentRange={dateRange} />
</div>
<div className={view === 'documents' ? 'overflow-hidden' : undefined}>
<DataTable<unknown, unknown>
columns={getCurrentColumns()}
data={getCurrentData()}
perPage={perPage}
currentPage={page}
totalPages={insights.totalPages}
onPaginationChange={onPaginationChange}
>
{(table) => <DataTablePagination additionalInformation="VisibleCount" table={table} />}
</DataTable>
</div>
{isLoading && (
<div className="absolute inset-0 flex items-center justify-center bg-white/50">
<Loader className="h-8 w-8 animate-spin text-gray-500" />
</div>
)}
</div>
);
};
const SummaryCard = ({
icon: Icon,
title,
value,
subtitle,
}: {
icon: React.ComponentType<{ className?: string }>;
title: string;
value: number;
subtitle?: string;
}) => (
<div className="bg-card flex items-start gap-x-2 rounded-lg border px-4 py-3">
<Icon className="text-muted-foreground h-4 w-4 items-start" />
<div className="-mt-0.5 space-y-2">
<p className="text-muted-foreground text-sm font-medium">{title}</p>
<p className="text-2xl font-bold">{value}</p>
{subtitle && <p className="text-muted-foreground text-xs">{subtitle}</p>}
</div>
</div>
);

View File

@ -56,7 +56,14 @@ export const UserOrganisationsTable = () => {
avatarFallback={row.original.name.slice(0, 1).toUpperCase()}
primaryText={
<span className="text-foreground/80 font-semibold">
{isPersonalLayoutMode ? _(msg`Personal`) : row.original.name}
{isPersonalLayoutMode
? _(
msg({
message: `Personal`,
context: `Personal organisation (adjective)`,
}),
)
: row.original.name}
</span>
}
secondaryText={

View File

@ -88,14 +88,12 @@ export default function Layout({ loaderData, params, matches }: Route.ComponentP
? {
heading: msg`Organisation not found`,
subHeading: msg`404 Organisation not found`,
message: msg`The organisation you are looking for may have been removed, renamed or may have never
existed.`,
message: msg`The organisation you are looking for may have been removed, renamed or may have never existed.`,
}
: {
heading: msg`Team not found`,
subHeading: msg`404 Team not found`,
message: msg`The team you are looking for may have been removed, renamed or may have never
existed.`,
message: msg`The team you are looking for may have been removed, renamed or may have never existed.`,
},
}}
primaryButton={

View File

@ -114,13 +114,13 @@ export default function AdminLayout() {
variant="ghost"
className={cn(
'justify-start md:w-full',
pathname?.startsWith('/admin/leaderboard') && 'bg-secondary',
pathname?.startsWith('/admin/organisation-insights') && 'bg-secondary',
)}
asChild
>
<Link to="/admin/leaderboard">
<Link to="/admin/organisation-insights">
<Trophy className="mr-2 h-5 w-5" />
<Trans>Leaderboard</Trans>
<Trans>Organisation Insights</Trans>
</Link>
</Button>
@ -128,7 +128,7 @@ export default function AdminLayout() {
variant="ghost"
className={cn(
'justify-start md:w-full',
pathname?.startsWith('/admin/banner') && 'bg-secondary',
pathname?.startsWith('/admin/site-settings') && 'bg-secondary',
)}
asChild
>

View File

@ -1,77 +0,0 @@
import { Trans } from '@lingui/react/macro';
import { getSigningVolume } from '@documenso/lib/server-only/admin/get-signing-volume';
import {
AdminLeaderboardTable,
type SigningVolume,
} from '~/components/tables/admin-leaderboard-table';
import type { Route } from './+types/leaderboard';
export async function loader({ request }: Route.LoaderArgs) {
const url = new URL(request.url);
const rawSortBy = url.searchParams.get('sortBy') || 'signingVolume';
const rawSortOrder = url.searchParams.get('sortOrder') || 'desc';
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const sortOrder = (['asc', 'desc'].includes(rawSortOrder) ? rawSortOrder : 'desc') as
| 'asc'
| 'desc';
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const sortBy = (
['name', 'createdAt', 'signingVolume'].includes(rawSortBy) ? rawSortBy : 'signingVolume'
) as 'name' | 'createdAt' | 'signingVolume';
const page = Number(url.searchParams.get('page')) || 1;
const perPage = Number(url.searchParams.get('perPage')) || 10;
const search = url.searchParams.get('search') || '';
const { leaderboard, totalPages } = await getSigningVolume({
search,
page,
perPage,
sortBy,
sortOrder,
});
const typedSigningVolume: SigningVolume[] = leaderboard.map((item) => ({
...item,
name: item.name || '',
createdAt: item.createdAt || new Date(),
}));
return {
signingVolume: typedSigningVolume,
totalPages,
page,
perPage,
sortBy,
sortOrder,
};
}
export default function Leaderboard({ loaderData }: Route.ComponentProps) {
const { signingVolume, totalPages, page, perPage, sortBy, sortOrder } = loaderData;
return (
<div>
<div className="flex items-center">
<h2 className="text-4xl font-semibold">
<Trans>Signing Volume</Trans>
</h2>
</div>
<div className="mt-8">
<AdminLeaderboardTable
signingVolume={signingVolume}
totalPages={totalPages}
page={page}
perPage={perPage}
sortBy={sortBy}
sortOrder={sortOrder}
/>
</div>
</div>
);
}

View File

@ -0,0 +1,59 @@
import { getOrganisationDetailedInsights } from '@documenso/lib/server-only/admin/get-organisation-detailed-insights';
import type { DateRange } from '@documenso/lib/types/search-params';
import { getAdminOrganisation } from '@documenso/trpc/server/admin-router/get-admin-organisation';
import { OrganisationInsightsTable } from '~/components/tables/organisation-insights-table';
import type { Route } from './+types/organisation-insights.$id';
export async function loader({ params, request }: Route.LoaderArgs) {
const { id } = params;
const url = new URL(request.url);
const page = Number(url.searchParams.get('page')) || 1;
const perPage = Number(url.searchParams.get('perPage')) || 10;
const dateRange = (url.searchParams.get('dateRange') || 'last30days') as DateRange;
const view = (url.searchParams.get('view') || 'teams') as 'teams' | 'users' | 'documents';
const [insights, organisation] = await Promise.all([
getOrganisationDetailedInsights({
organisationId: id,
page,
perPage,
dateRange,
view,
}),
getAdminOrganisation({ organisationId: id }),
]);
return {
organisationId: id,
organisationName: organisation.name,
insights,
page,
perPage,
dateRange,
view,
};
}
export default function OrganisationInsights({ loaderData }: Route.ComponentProps) {
const { insights, page, perPage, dateRange, view, organisationName } = loaderData;
return (
<div>
<div className="flex items-center justify-between">
<h2 className="text-4xl font-semibold">{organisationName}</h2>
</div>
<div className="mt-8">
<OrganisationInsightsTable
insights={insights}
page={page}
perPage={perPage}
dateRange={dateRange}
view={view}
/>
</div>
</div>
);
}

View File

@ -0,0 +1,91 @@
import { Trans } from '@lingui/react/macro';
import { getOrganisationInsights } from '@documenso/lib/server-only/admin/get-signing-volume';
import type { DateRange } from '@documenso/lib/types/search-params';
import { DateRangeFilter } from '~/components/filters/date-range-filter';
import {
AdminOrganisationOverviewTable,
type OrganisationOverview,
} from '~/components/tables/admin-organisation-overview-table';
import type { Route } from './+types/organisation-insights._index';
export async function loader({ request }: Route.LoaderArgs) {
const url = new URL(request.url);
const rawSortBy = url.searchParams.get('sortBy') || 'signingVolume';
const rawSortOrder = url.searchParams.get('sortOrder') || 'desc';
const isSortOrder = (value: string): value is 'asc' | 'desc' =>
value === 'asc' || value === 'desc';
const isSortBy = (value: string): value is 'name' | 'createdAt' | 'signingVolume' =>
value === 'name' || value === 'createdAt' || value === 'signingVolume';
const sortOrder: 'asc' | 'desc' = isSortOrder(rawSortOrder) ? rawSortOrder : 'desc';
const sortBy: 'name' | 'createdAt' | 'signingVolume' = isSortBy(rawSortBy)
? rawSortBy
: 'signingVolume';
const page = Number(url.searchParams.get('page')) || 1;
const perPage = Number(url.searchParams.get('perPage')) || 10;
const search = url.searchParams.get('search') || '';
const dateRange = (url.searchParams.get('dateRange') || 'last30days') as DateRange;
const { organisations, totalPages } = await getOrganisationInsights({
search,
page,
perPage,
sortBy,
sortOrder,
dateRange,
});
const typedOrganisations: OrganisationOverview[] = organisations.map((item) => ({
id: String(item.id),
name: item.name || '',
signingVolume: item.signingVolume,
createdAt: item.createdAt || new Date(),
customerId: item.customerId || '',
subscriptionStatus: item.subscriptionStatus,
teamCount: item.teamCount || 0,
memberCount: item.memberCount || 0,
}));
return {
organisations: typedOrganisations,
totalPages,
page,
perPage,
sortBy,
sortOrder,
dateRange,
};
}
export default function Organisations({ loaderData }: Route.ComponentProps) {
const { organisations, totalPages, page, perPage, sortBy, sortOrder, dateRange } = loaderData;
return (
<div>
<div className="flex items-center justify-between">
<h2 className="text-4xl font-semibold">
<Trans>Organisation Insights</Trans>
</h2>
<DateRangeFilter currentRange={dateRange} />
</div>
<div className="mt-8">
<AdminOrganisationOverviewTable
organisations={organisations}
totalPages={totalPages}
page={page}
perPage={perPage}
sortBy={sortBy}
sortOrder={sortOrder}
dateRange={dateRange}
/>
</div>
</div>
);
}

View File

@ -142,8 +142,7 @@ export default function OrganisationGroupSettingsPage({ params }: Route.Componen
404: {
heading: msg`Organisation not found`,
subHeading: msg`404 Organisation not found`,
message: msg`The organisation you are looking for may have been removed, renamed or may have never
existed.`,
message: msg`The organisation you are looking for may have been removed, renamed or may have never existed.`,
},
}}
primaryButton={

View File

@ -59,8 +59,7 @@ export default function UserPage({ params }: { params: { id: number } }) {
404: {
heading: msg`User not found`,
subHeading: msg`404 User not found`,
message: msg`The user you are looking for may have been removed, renamed or may have never
existed.`,
message: msg`The user you are looking for may have been removed, renamed or may have never existed.`,
},
}}
primaryButton={

View File

@ -117,8 +117,7 @@ export default function OrganisationEmailDomainSettingsPage({ params }: Route.Co
404: {
heading: msg`Email domain not found`,
subHeading: msg`404 Email domain not found`,
message: msg`The email domain you are looking for may have been removed, renamed or may have never
existed.`,
message: msg`The email domain you are looking for may have been removed, renamed or may have never existed.`,
},
}}
primaryButton={

View File

@ -89,8 +89,7 @@ export default function OrganisationGroupSettingsPage({ params }: Route.Componen
404: {
heading: msg`Organisation group not found`,
subHeading: msg`404 Organisation group not found`,
message: msg`The organisation group you are looking for may have been removed, renamed or may have never
existed.`,
message: msg`The organisation group you are looking for may have been removed, renamed or may have never existed.`,
},
}}
primaryButton={

View File

@ -60,8 +60,7 @@ export default function Layout() {
404: {
heading: msg`Team not found`,
subHeading: msg`404 Team not found`,
message: msg`The team you are looking for may have been removed, renamed or may have never
existed.`,
message: msg`The team you are looking for may have been removed, renamed or may have never existed.`,
},
}}
primaryButton={

View File

@ -71,8 +71,7 @@ export default function DocumentPage({ params }: Route.ComponentProps) {
404: {
heading: msg`Not found`,
subHeading: msg`404 Not found`,
message: msg`The document you are looking for may have been removed, renamed or may have never
existed.`,
message: msg`The document you are looking for may have been removed, renamed or may have never existed.`,
},
}}
primaryButton={
@ -127,7 +126,11 @@ export default function DocumentPage({ params }: Route.ComponentProps) {
position="bottom"
>
<span>
<Trans>{envelope.recipients.length} Recipient(s)</Trans>
<Plural
value={envelope.recipients.length}
one="# Recipient"
other="# Recipients"
/>
</span>
</StackAvatarsWithTooltip>
</div>

View File

@ -82,8 +82,7 @@ export default function EnvelopeEditorPage({ params }: Route.ComponentProps) {
404: {
heading: msg`Not found`,
subHeading: msg`404 Not found`,
message: msg`The document you are looking for may have been removed, renamed or may have never
existed.`,
message: msg`The document you are looking for may have been removed, renamed or may have never existed.`,
},
}}
primaryButton={

View File

@ -1,6 +1,7 @@
import { useEffect, useMemo, useState } from 'react';
import { Trans } from '@lingui/react/macro';
import { EnvelopeType } from '@prisma/client';
import { FolderType, OrganisationType } from '@prisma/client';
import { useParams, useSearchParams } from 'react-router';
import { Link } from 'react-router';
@ -18,9 +19,9 @@ import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/av
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
import { DocumentMoveToFolderDialog } from '~/components/dialogs/document-move-to-folder-dialog';
import { DocumentDropZoneWrapper } from '~/components/general/document/document-drop-zone-wrapper';
import { DocumentSearch } from '~/components/general/document/document-search';
import { DocumentStatus } from '~/components/general/document/document-status';
import { EnvelopeDropZoneWrapper } from '~/components/general/envelope/envelope-drop-zone-wrapper';
import { FolderGrid } from '~/components/general/folder/folder-grid';
import { PeriodSelector } from '~/components/general/period-selector';
import { DocumentsTable } from '~/components/tables/documents-table';
@ -108,9 +109,8 @@ export default function DocumentsPage() {
}
}, [data?.stats]);
// Todo: Envelopes - Change the dropzone wrapper to create to V2 documents after we're ready.
return (
<DocumentDropZoneWrapper>
<EnvelopeDropZoneWrapper type={EnvelopeType.DOCUMENT}>
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
<FolderGrid type={FolderType.DOCUMENT} parentId={folderId ?? null} />
@ -210,6 +210,6 @@ export default function DocumentsPage() {
/>
)}
</div>
</DocumentDropZoneWrapper>
</EnvelopeDropZoneWrapper>
);
}

View File

@ -109,8 +109,7 @@ export default function WebhookPage({ params }: Route.ComponentProps) {
404: {
heading: msg`Webhook not found`,
subHeading: msg`404 Webhook not found`,
message: msg`The webhook you are looking for may have been removed, renamed or may have never
existed.`,
message: msg`The webhook you are looking for may have been removed, renamed or may have never existed.`,
},
}}
primaryButton={

View File

@ -66,8 +66,7 @@ export default function TemplatePage({ params }: Route.ComponentProps) {
404: {
heading: msg`Not found`,
subHeading: msg`404 Not found`,
message: msg`The template you are looking for may have been removed, renamed or may have never
existed.`,
message: msg`The template you are looking for may have been removed, renamed or may have never existed.`,
},
}}
primaryButton={

View File

@ -1,4 +1,5 @@
import { Trans } from '@lingui/react/macro';
import { EnvelopeType } from '@prisma/client';
import { Bird } from 'lucide-react';
import { useParams, useSearchParams } from 'react-router';
@ -8,8 +9,8 @@ import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/t
import { trpc } from '@documenso/trpc/react';
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
import { EnvelopeDropZoneWrapper } from '~/components/general/envelope/envelope-drop-zone-wrapper';
import { FolderGrid } from '~/components/general/folder/folder-grid';
import { TemplateDropZoneWrapper } from '~/components/general/template/template-drop-zone-wrapper';
import { TemplatesTable } from '~/components/tables/templates-table';
import { useCurrentTeam } from '~/providers/team';
import { appMetaTags } from '~/utils/meta';
@ -37,7 +38,7 @@ export default function TemplatesPage() {
});
return (
<TemplateDropZoneWrapper>
<EnvelopeDropZoneWrapper type={EnvelopeType.TEMPLATE}>
<div className="mx-auto max-w-screen-xl px-4 md:px-8">
<FolderGrid type={FolderType.TEMPLATE} parentId={folderId ?? null} />
@ -85,6 +86,6 @@ export default function TemplatesPage() {
</div>
</div>
</div>
</TemplateDropZoneWrapper>
</EnvelopeDropZoneWrapper>
);
}

View File

@ -8,7 +8,7 @@ import { SignFieldNumberDialog } from '~/components/dialogs/sign-field-number-di
type HandleNumberFieldClickOptions = {
field: TFieldNumber;
number: number | null;
number: string | null;
};
export const handleNumberFieldClick = async (

View File

@ -14,6 +14,8 @@
"with:env": "dotenv -e ../../.env -e ../../.env.local --"
},
"dependencies": {
"@ai-sdk/google": "^2.0.25",
"@ai-sdk/react": "^2.0.82",
"@cantoo/pdf-lib": "^2.5.2",
"@documenso/api": "*",
"@documenso/assets": "*",
@ -39,6 +41,7 @@
"@react-router/serve": "^7.6.0",
"@simplewebauthn/browser": "^9.0.1",
"@simplewebauthn/server": "^9.0.3",
"ai": "^5.0.82",
"autoprefixer": "^10.4.13",
"colord": "^2.9.3",
"content-disposition": "^0.5.4",
@ -70,6 +73,7 @@
"remix-themes": "^2.0.4",
"satori": "^0.12.1",
"sharp": "0.32.6",
"skia-canvas": "^3.0.8",
"tailwindcss": "^3.4.15",
"ts-pattern": "^5.0.5",
"ua-parser-js": "^1.0.37",
@ -106,5 +110,5 @@
"vite-plugin-babel-macros": "^1.0.6",
"vite-tsconfig-paths": "^5.1.4"
},
"version": "2.0.2"
"version": "2.0.7"
}

420
apps/remix/server/api/ai.ts Normal file
View File

@ -0,0 +1,420 @@
// sort-imports-ignore
// ---- PATCH pdfjs-dist's canvas require BEFORE importing it ----
import { createRequire } from 'module';
import { fileURLToPath } from 'url';
import { Canvas, Image } from 'skia-canvas';
const require = createRequire(import.meta.url || fileURLToPath(new URL('.', import.meta.url)));
const Module = require('module');
const originalRequire = Module.prototype.require;
Module.prototype.require = function (path: string) {
if (path === 'canvas') {
return {
createCanvas: (width: number, height: number) => new Canvas(width, height),
Image, // needed by pdfjs-dist
};
}
// eslint-disable-next-line prefer-rest-params, @typescript-eslint/consistent-type-assertions
return originalRequire.apply(this, arguments as unknown as [string]);
};
// Use dynamic require to bypass Vite SSR transformation
// eslint-disable-next-line @typescript-eslint/no-var-requires
const pdfjsLib = require('pdfjs-dist/legacy/build/pdf.js');
import { generateObject } from 'ai';
import { mkdir, writeFile } from 'fs/promises';
import { Hono } from 'hono';
import { join } from 'path';
import sharp from 'sharp';
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { getTeamById } from '@documenso/lib/server-only/team/get-team';
import { getFileServerSide } from '@documenso/lib/universal/upload/get-file.server';
import { env } from '@documenso/lib/utils/env';
import { prisma } from '@documenso/prisma';
import type { HonoEnv } from '../router';
import {
type TDetectFormFieldsResponse,
ZDetectFormFieldsRequestSchema,
ZDetectedFormFieldSchema,
} from './ai.types';
const renderPdfToImage = async (pdfBytes: Uint8Array) => {
const loadingTask = pdfjsLib.getDocument({ data: pdfBytes });
const pdf = await loadingTask.promise;
try {
const scale = 4;
const pages = await Promise.all(
Array.from({ length: pdf.numPages }, async (_, index) => {
const pageNumber = index + 1;
const page = await pdf.getPage(pageNumber);
try {
const viewport = page.getViewport({ scale });
const virtualCanvas = new Canvas(viewport.width, viewport.height);
const context = virtualCanvas.getContext('2d');
context.imageSmoothingEnabled = false;
await page.render({ canvasContext: context, viewport }).promise;
return {
image: await virtualCanvas.toBuffer('png'),
pageNumber,
width: Math.floor(viewport.width),
height: Math.floor(viewport.height),
};
} finally {
page.cleanup();
}
}),
);
return pages;
} finally {
await pdf.destroy();
}
};
const resizeAndCompressImage = async (imageBuffer: Buffer): Promise<Buffer> => {
const metadata = await sharp(imageBuffer).metadata();
const originalWidth = metadata.width || 0;
if (originalWidth > 1000) {
return await sharp(imageBuffer)
.resize({ width: 1000, withoutEnlargement: true })
.jpeg({ quality: 70 })
.toBuffer();
}
return await sharp(imageBuffer).jpeg({ quality: 70 }).toBuffer();
};
const detectObjectsPrompt = `You are analyzing a form document image to detect fillable fields for the Documenso document signing platform.
IMPORTANT RULES:
1. Only detect EMPTY/UNFILLED fields (ignore boxes that already contain text or data)
2. Analyze nearby text labels to determine the field type
3. Return bounding boxes for the fillable area only, NOT the label text
4. Each boundingBox must be in the format [ymin, xmin, ymax, xmax] where all coordinates are NORMALIZED to a 0-1000 scale
CRITICAL: UNDERSTANDING FILLABLE AREAS
The "fillable area" is ONLY the empty space where a user will write, type, sign, or check.
- ✓ CORRECT: The blank underscore where someone writes their name: "Name: _________" → box ONLY the underscores
- ✓ CORRECT: The empty white rectangle inside a box outline → box ONLY the empty space, not any printed text
- ✓ CORRECT: The blank space to the right of a label: "Email: [ empty box ]" → box ONLY the empty box, exclude "Email:"
- ✗ INCORRECT: Including the word "Signature:" that appears to the left of a signature line
- ✗ INCORRECT: Including printed labels, instructions, or descriptive text near the field
- ✗ INCORRECT: Extending the box to include text just because it's close to the fillable area
VISUALIZING THE DISTINCTION:
- If there's text (printed words/labels) near an empty box or line, they are SEPARATE elements
- The text is a LABEL telling the user what to fill
- The empty space is the FILLABLE AREA where they actually write/sign
- Your bounding box should capture ONLY the empty space, even if the label is immediately adjacent
FIELD TYPES TO DETECT:
• SIGNATURE - Signature lines, boxes labeled 'Signature', 'Sign here', 'Authorized signature', 'X____'
• INITIALS - Small boxes labeled 'Initials', 'Initial here', typically smaller than signature fields
• NAME - Boxes labeled 'Name', 'Full name', 'Your name', 'Print name', 'Printed name'
• EMAIL - Boxes labeled 'Email', 'Email address', 'E-mail', 'Email:'
• DATE - Boxes labeled 'Date', 'Date signed', "Today's date", or showing date format placeholders like 'MM/DD/YYYY', '__/__/____'
• CHECKBOX - Empty checkbox squares (☐) with or without labels, typically small square boxes
• RADIO - Empty radio button circles (○) in groups, typically circular selection options
• NUMBER - Boxes labeled with numeric context: 'Amount', 'Quantity', 'Phone', 'Phone number', 'ZIP', 'ZIP code', 'Age', 'Price', '#'
• DROPDOWN - Boxes with dropdown indicators (▼, ↓) or labeled 'Select', 'Choose', 'Please select'
• TEXT - Any other empty text input boxes, general input fields, unlabeled boxes, or when field type is uncertain
DETECTION GUIDELINES:
- Read text located near the box (above, to the left, or inside the box boundary) to infer the field type
- IMPORTANT: Use the nearby text to CLASSIFY the field type, but DO NOT include that text in the bounding box
- If you're uncertain which type fits best, default to TEXT
- For checkboxes and radio buttons: Detect each individual box/circle separately, not the label
- Signature fields are often longer horizontal lines or larger boxes
- Date fields often show format hints or date separators (slashes, dashes)
- Look for visual patterns: underscores (____), horizontal lines, box outlines
BOUNDING BOX PLACEMENT (CRITICAL):
- Your coordinates must capture ONLY the empty fillable space (the blank area where input goes)
- EXCLUDE all printed text labels, even if they are:
· Directly to the left of the field (e.g., "Name: _____")
· Directly above the field (e.g., "Signature" printed above a line)
· Very close to the field with minimal spacing
· Inside the same outlined box as the fillable area
- The label text helps you IDENTIFY the field type, but must be EXCLUDED from the bounding box
- If you detect a label "Email:" followed by a blank box, draw the box around ONLY the blank box, not the word "Email:"
COORDINATE SYSTEM:
- [ymin, xmin, ymax, xmax] normalized to 0-1000 scale
- Top-left corner: ymin and xmin close to 0
- Bottom-right corner: ymax and xmax close to 1000
- Coordinates represent positions on a 1000x1000 grid overlaid on the image
FIELD SIZING STRATEGY FOR LINE-BASED FIELDS:
When detecting thin horizontal lines for SIGNATURE, INITIALS, NAME, EMAIL, DATE, TEXT, or NUMBER fields:
1. Analyze the visual context around the detected line:
- Look at the empty space ABOVE the detected line
- Observe the spacing to any text labels, headers, or other form elements above
- Assess what would be a reasonable field height to make the field clearly visible when filled
2. Expand UPWARD from the detected line to create a usable field:
- Keep ymax (bottom) at the detected line position (the line becomes the bottom edge)
- Extend ymin (top) upward into the available whitespace
- Aim to use 60-80% of the clear whitespace above the line, while being reasonable
- The expanded field should provide comfortable space for signing/writing (minimum 30 units tall)
3. Apply minimum dimensions: height at least 30 units (3% of 1000-scale), width at least 36 units
4. Ensure ymin >= 0 (do not go off-page). If ymin would be negative, clamp to 0
5. Do NOT apply this expansion to CHECKBOX, RADIO, or DROPDOWN fields - use detected dimensions for those
6. Example: If you detect a signature line at ymax=500 with clear whitespace extending up to y=400:
- Available whitespace: 100 units
- Use 60-80% of that: 60-80 units
- Expanded field: [ymin=420, xmin=200, ymax=500, xmax=600] (creates 80-unit tall field)
- This gives comfortable signing space while respecting the form layout`;
const runFormFieldDetection = async (
imageBuffer: Buffer,
pageNumber: number,
): Promise<TDetectFormFieldsResponse> => {
const compressedImageBuffer = await resizeAndCompressImage(imageBuffer);
const base64Image = compressedImageBuffer.toString('base64');
const result = await generateObject({
model: 'google/gemini-2.5-pro',
output: 'array',
schema: ZDetectedFormFieldSchema,
messages: [
{
role: 'user',
content: [
{
type: 'image',
image: `data:image/jpeg;base64,${base64Image}`,
},
{
type: 'text',
text: detectObjectsPrompt,
},
],
},
],
});
return result.object.map((field) => ({
...field,
pageNumber,
}));
};
export const aiRoute = new Hono<HonoEnv>().post('/detect-form-fields', async (c) => {
try {
const { user } = await getSession(c.req.raw);
const body = await c.req.json();
const parsed = ZDetectFormFieldsRequestSchema.safeParse(body);
if (!parsed.success) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Document ID is required',
userMessage: 'Please provide a valid document ID.',
});
}
const { documentId } = parsed.data;
const documentData = await prisma.documentData.findUnique({
where: { id: documentId },
include: {
envelopeItem: {
include: {
envelope: {
select: {
userId: true,
teamId: true,
},
},
},
},
},
});
if (!documentData || !documentData.envelopeItem) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: `Document data not found: ${documentId}`,
userMessage: 'The requested document does not exist.',
});
}
const envelope = documentData.envelopeItem.envelope;
const isDirectOwner = envelope.userId === user.id;
let hasTeamAccess = false;
if (envelope.teamId) {
try {
await getTeamById({ teamId: envelope.teamId, userId: user.id });
hasTeamAccess = true;
} catch (error) {
hasTeamAccess = false;
}
}
if (!isDirectOwner && !hasTeamAccess) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: `User ${user.id} does not have access to document ${documentId}`,
userMessage: 'You do not have permission to access this document.',
});
}
const pdfBytes = await getFileServerSide({
type: documentData.type,
data: documentData.initialData || documentData.data,
});
const renderedPages = await renderPdfToImage(pdfBytes);
const results = await Promise.allSettled(
renderedPages.map(async (page) => {
return await runFormFieldDetection(page.image, page.pageNumber);
}),
);
const detectedFields: TDetectFormFieldsResponse = [];
for (const [index, result] of results.entries()) {
if (result.status === 'fulfilled') {
detectedFields.push(...result.value);
} else {
const pageNumber = renderedPages[index]?.pageNumber ?? index + 1;
console.error(`Failed to detect fields on page ${pageNumber}:`, result.reason);
}
}
if (env('NEXT_PUBLIC_AI_DEBUG_PREVIEW') === 'true') {
const debugDir = join(process.cwd(), '..', '..', 'packages', 'assets', 'ai-previews');
await mkdir(debugDir, { recursive: true });
const now = new Date();
const timestamp = now
.toISOString()
.replace(/[-:]/g, '')
.replace(/\..+/, '')
.replace('T', '_');
for (const page of renderedPages) {
const padding = { left: 80, top: 20, right: 20, bottom: 40 };
const canvas = new Canvas(
page.width + padding.left + padding.right,
page.height + padding.top + padding.bottom,
);
const ctx = canvas.getContext('2d');
const img = new Image();
img.src = page.image;
ctx.drawImage(img, padding.left, padding.top);
ctx.strokeStyle = 'rgba(255, 0, 0, 0.5)';
ctx.lineWidth = 1;
for (let i = 0; i <= 1000; i += 100) {
const x = padding.left + (i / 1000) * page.width;
ctx.beginPath();
ctx.moveTo(x, padding.top);
ctx.lineTo(x, page.height + padding.top);
ctx.stroke();
}
for (let i = 0; i <= 1000; i += 100) {
const y = padding.top + (i / 1000) * page.height;
ctx.beginPath();
ctx.moveTo(padding.left, y);
ctx.lineTo(page.width + padding.left, y);
ctx.stroke();
}
const colors = ['#FF0000', '#00FF00', '#0000FF', '#FFFF00', '#FF00FF', '#00FFFF'];
const pageFields = detectedFields.filter((f) => f.pageNumber === page.pageNumber);
pageFields.forEach((field, index) => {
const [ymin, xmin, ymax, xmax] = field.boundingBox.map((coord) => coord / 1000);
const x = xmin * page.width + padding.left;
const y = ymin * page.height + padding.top;
const width = (xmax - xmin) * page.width;
const height = (ymax - ymin) * page.height;
ctx.strokeStyle = colors[index % colors.length];
ctx.lineWidth = 5;
ctx.strokeRect(x, y, width, height);
ctx.fillStyle = colors[index % colors.length];
ctx.font = '20px Arial';
ctx.fillText(field.label, x, y - 5);
});
ctx.strokeStyle = '#000000';
ctx.lineWidth = 1;
ctx.font = '26px Arial';
ctx.beginPath();
ctx.moveTo(padding.left, padding.top);
ctx.lineTo(padding.left, page.height + padding.top);
ctx.stroke();
ctx.textAlign = 'right';
ctx.textBaseline = 'middle';
for (let i = 0; i <= 1000; i += 100) {
const y = padding.top + (i / 1000) * page.height;
ctx.fillStyle = '#000000';
ctx.fillText(i.toString(), padding.left - 5, y);
ctx.beginPath();
ctx.moveTo(padding.left - 5, y);
ctx.lineTo(padding.left, y);
ctx.stroke();
}
ctx.beginPath();
ctx.moveTo(padding.left, page.height + padding.top);
ctx.lineTo(page.width + padding.left, page.height + padding.top);
ctx.stroke();
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
for (let i = 0; i <= 1000; i += 100) {
const x = padding.left + (i / 1000) * page.width;
ctx.fillStyle = '#000000';
ctx.fillText(i.toString(), x, page.height + padding.top + 5);
ctx.beginPath();
ctx.moveTo(x, page.height + padding.top);
ctx.lineTo(x, page.height + padding.top + 5);
ctx.stroke();
}
const outputFilename = `detected_form_fields_${timestamp}_page_${page.pageNumber}.png`;
const outputPath = join(debugDir, outputFilename);
const pngBuffer = await canvas.toBuffer('png');
await writeFile(outputPath, new Uint8Array(pngBuffer));
}
}
return c.json<TDetectFormFieldsResponse>(detectedFields);
} catch (error) {
if (error instanceof AppError) {
throw error;
}
console.error('Failed to detect form fields from PDF:', error);
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
message: `Failed to detect form fields from PDF: ${error instanceof Error ? error.message : String(error)}`,
userMessage: 'An error occurred while detecting form fields. Please try again.',
});
}
});

View File

@ -0,0 +1,50 @@
import { z } from 'zod';
import type { TDetectedFormField } from '@documenso/lib/types/ai';
export const ZGenerateTextRequestSchema = z.object({
prompt: z.string().min(1, 'Prompt is required').max(5000, 'Prompt is too long'),
});
export const ZGenerateTextResponseSchema = z.object({
text: z.string(),
});
export type TGenerateTextRequest = z.infer<typeof ZGenerateTextRequestSchema>;
export type TGenerateTextResponse = z.infer<typeof ZGenerateTextResponseSchema>;
export const ZDetectedFormFieldSchema = z.object({
boundingBox: z
.array(z.number())
.length(4)
.describe('Bounding box [ymin, xmin, ymax, xmax] in normalized 0-1000 range'),
label: z
.enum([
'SIGNATURE',
'INITIALS',
'NAME',
'EMAIL',
'DATE',
'TEXT',
'NUMBER',
'RADIO',
'CHECKBOX',
'DROPDOWN',
])
.describe('Documenso field type inferred from nearby label text or visual characteristics'),
pageNumber: z
.number()
.int()
.positive()
.describe('1-indexed page number where field was detected'),
});
export const ZDetectFormFieldsRequestSchema = z.object({
documentId: z.string().min(1, { message: 'Document ID is required' }),
});
export const ZDetectFormFieldsResponseSchema = z.array(ZDetectedFormFieldSchema);
export type TDetectFormFieldsRequest = z.infer<typeof ZDetectFormFieldsRequestSchema>;
export type TDetectFormFieldsResponse = z.infer<typeof ZDetectFormFieldsResponseSchema>;
export type { TDetectedFormField };

View File

@ -14,6 +14,7 @@ import { getIpAddress } from '@documenso/lib/universal/get-ip-address';
import { logger } from '@documenso/lib/utils/logger';
import { openApiDocument } from '@documenso/trpc/server/open-api';
import { aiRoute } from './api/ai';
import { downloadRoute } from './api/download/download';
import { filesRoute } from './api/files/files';
import { type AppContext, appContext } from './context';
@ -84,6 +85,9 @@ app.route('/api/auth', auth);
// Files route.
app.route('/api/files', filesRoute);
// AI route.
app.route('/api/ai', aiRoute);
// API servers.
app.use(`/api/v1/*`, cors());
app.route('/api/v1', tsRestHonoApp);

View File

@ -2,12 +2,19 @@ import { lingui } from '@lingui/vite-plugin';
import { reactRouter } from '@react-router/dev/vite';
import autoprefixer from 'autoprefixer';
import serverAdapter from 'hono-react-router-adapter/vite';
import { createRequire } from 'node:module';
import path from 'node:path';
import tailwindcss from 'tailwindcss';
import { defineConfig } from 'vite';
import { defineConfig, normalizePath } from 'vite';
import macrosPlugin from 'vite-plugin-babel-macros';
import { viteStaticCopy } from 'vite-plugin-static-copy';
import tsconfigPaths from 'vite-tsconfig-paths';
const require = createRequire(import.meta.url);
const pdfjsDistPath = path.dirname(require.resolve('pdfjs-dist/package.json'));
const cMapsDir = normalizePath(path.join(pdfjsDistPath, 'cmaps'));
/**
* Note: We load the env variables externally so we can have runtime enviroment variables
* for docker.
@ -25,6 +32,14 @@ export default defineConfig({
strictPort: true,
},
plugins: [
viteStaticCopy({
targets: [
{
src: cMapsDir,
dest: 'static',
},
],
}),
reactRouter(),
macrosPlugin(),
lingui(),

Binary file not shown.

View File

@ -4,6 +4,7 @@
FROM node:22-alpine3.20 AS base
RUN apk add --no-cache openssl
RUN apk add --no-cache font-freefont
###########################

8079
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{
"private": true,
"version": "2.0.2",
"version": "2.0.7",
"scripts": {
"build": "turbo run build",
"dev": "turbo run dev --filter=@documenso/remix",
@ -45,6 +45,12 @@
"@commitlint/config-conventional": "^17.7.0",
"@lingui/cli": "^5.2.0",
"@prisma/client": "^6.18.0",
"@trpc/client": "11.7.0",
"@trpc/react-query": "11.7.0",
"@trpc/server": "11.7.0",
"@ts-rest/core": "^3.52.1",
"@ts-rest/open-api": "^3.52.1",
"@ts-rest/serverless": "^3.52.1",
"dotenv": "^16.5.0",
"dotenv-cli": "^8.0.0",
"eslint": "^8.40.0",
@ -59,18 +65,13 @@
"prisma-json-types-generator": "^3.6.2",
"prisma-kysely": "^1.8.0",
"rimraf": "^5.0.1",
"turbo": "^1.9.3",
"@trpc/client": "11.7.0",
"@trpc/react-query": "11.7.0",
"@trpc/server": "11.7.0",
"superjson": "^2.2.5",
"trpc-to-openapi": "2.4.0",
"turbo": "^1.9.3",
"vite": "^6.3.5",
"vite-plugin-static-copy": "^3.1.4",
"zod-openapi": "^4.2.4",
"@ts-rest/core": "^3.52.1",
"@ts-rest/open-api": "^3.52.1",
"@ts-rest/serverless": "^3.52.1",
"zod-prisma-types": "3.3.5",
"vite": "^6.3.5"
"zod-prisma-types": "3.3.5"
},
"name": "@documenso/root",
"workspaces": [
@ -82,6 +83,7 @@
"@documenso/prisma": "^0.0.0",
"@lingui/conf": "^5.2.0",
"@lingui/core": "^5.2.0",
"ai": "^5.0.82",
"inngest-cli": "^0.29.1",
"luxon": "^3.5.0",
"mupdf": "^1.0.0",

View File

@ -1,4 +1,6 @@
import { FieldType } from '@prisma/client';
import fs from 'node:fs';
import path from 'node:path';
import type { TFieldAndMeta } from '@documenso/lib/types/field-meta';
import { toCheckboxCustomText } from '@documenso/lib/utils/fields';
@ -13,11 +15,66 @@ export type FieldTestData = TFieldAndMeta & {
signature?: string;
};
const columnWidth = 19.125;
const rowHeight = 6.7;
export const signatureBase64Demo = `data:image/png;base64,${fs.readFileSync(
path.join(__dirname, '../../../packages/assets/', 'logo_icon.png'),
'base64',
)}`;
const alignmentGridStartX = 31;
const alignmentGridStartY = 19.02;
const columnWidth = 19.125;
const fullColumnWidth = 57.37499999999998;
const rowHeight = 6.7;
const rowPadding = 0;
const calculatePositionPageOne = (
row: number,
column: number,
width: 'full' | 'column' = 'column',
) => {
const alignmentGridStartX = 31;
const alignmentGridStartY = 19;
return {
height: rowHeight,
width: width === 'full' ? fullColumnWidth : columnWidth,
positionX: alignmentGridStartX + (column ?? 0) * columnWidth,
positionY: alignmentGridStartY + row * (rowHeight + rowPadding),
};
};
const calculatePositionPageTwo = (
row: number,
column: number,
width: 'full' | 'column' = 'column',
) => {
const alignmentGridStartX = 31;
const alignmentGridStartY = 16.35;
return {
height: rowHeight,
width: width === 'full' ? fullColumnWidth : columnWidth,
positionX: alignmentGridStartX + (column ?? 0) * columnWidth,
positionY: alignmentGridStartY + row * (rowHeight + rowPadding),
};
};
const calculatePositionPageThree = (
row: number,
column: number,
width: 'full' | 'column' = 'column',
rowQuantity: number = 1,
) => {
const alignmentGridStartX = 31;
const alignmentGridStartY = 16.4;
const rowHeight = 6.8;
return {
height: rowHeight * rowQuantity,
width: width === 'full' ? fullColumnWidth : columnWidth,
positionX: alignmentGridStartX + (column ?? 0) * columnWidth,
positionY: alignmentGridStartY + row * (rowHeight + rowPadding),
};
};
export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [
/**
@ -31,10 +88,7 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [
type: 'email',
},
page: 1,
height: rowHeight,
width: columnWidth,
positionX: 0,
positionY: 0,
...calculatePositionPageOne(0, 0),
customText: 'admin@documenso.com',
},
{
@ -44,10 +98,7 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [
type: 'email',
},
page: 1,
height: rowHeight,
width: columnWidth,
positionX: 0,
positionY: 0,
...calculatePositionPageOne(0, 1),
customText: 'admin@documenso.com',
},
{
@ -58,10 +109,7 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [
type: 'email',
},
page: 1,
height: rowHeight,
width: columnWidth,
positionX: 0,
positionY: 0,
...calculatePositionPageOne(0, 2),
customText: 'admin@documenso.com',
},
/**
@ -75,10 +123,7 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [
type: 'name',
},
page: 1,
height: rowHeight,
width: columnWidth,
positionX: 0,
positionY: 0,
...calculatePositionPageOne(1, 0),
customText: 'John Doe',
},
{
@ -88,10 +133,7 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [
type: 'name',
},
page: 1,
height: rowHeight,
width: columnWidth,
positionX: 0,
positionY: 0,
...calculatePositionPageOne(1, 1),
customText: 'John Doe',
},
{
@ -102,10 +144,7 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [
type: 'name',
},
page: 1,
height: rowHeight,
width: columnWidth,
positionX: 0,
positionY: 0,
...calculatePositionPageOne(1, 2),
customText: 'John Doe',
},
/**
@ -119,10 +158,7 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [
type: 'date',
},
page: 1,
height: rowHeight,
width: columnWidth,
positionX: 0,
positionY: 0,
...calculatePositionPageOne(2, 0),
customText: '123456789',
},
{
@ -132,10 +168,7 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [
type: 'date',
},
page: 1,
height: rowHeight,
width: columnWidth,
positionX: 0,
positionY: 0,
...calculatePositionPageOne(2, 1),
customText: '123456789',
},
{
@ -146,10 +179,7 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [
type: 'date',
},
page: 1,
height: rowHeight,
width: columnWidth,
positionX: 0,
positionY: 0,
...calculatePositionPageOne(2, 2),
customText: '123456789',
},
/**
@ -163,10 +193,7 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [
type: 'text',
},
page: 1,
height: rowHeight,
width: columnWidth,
positionX: 0,
positionY: 0,
...calculatePositionPageOne(3, 0),
customText: '123456789',
},
{
@ -176,10 +203,7 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [
type: 'text',
},
page: 1,
height: rowHeight,
width: columnWidth,
positionX: 0,
positionY: 0,
...calculatePositionPageOne(3, 1),
customText: '123456789',
},
{
@ -190,10 +214,7 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [
type: 'text',
},
page: 1,
height: rowHeight,
width: columnWidth,
positionX: 0,
positionY: 0,
...calculatePositionPageOne(3, 2),
customText: '123456789',
},
/**
@ -207,10 +228,7 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [
type: 'number',
},
page: 1,
height: rowHeight,
width: columnWidth,
positionX: 0,
positionY: 0,
...calculatePositionPageOne(4, 0),
customText: '123456789',
},
{
@ -220,10 +238,7 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [
type: 'number',
},
page: 1,
height: rowHeight,
width: columnWidth,
positionX: 0,
positionY: 0,
...calculatePositionPageOne(4, 1),
customText: '123456789',
},
{
@ -234,10 +249,7 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [
type: 'number',
},
page: 1,
height: rowHeight,
width: columnWidth,
positionX: 0,
positionY: 0,
...calculatePositionPageOne(4, 2),
customText: '123456789',
},
/**
@ -251,10 +263,7 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [
type: 'initials',
},
page: 1,
height: rowHeight,
width: columnWidth,
positionX: 0,
positionY: 0,
...calculatePositionPageOne(5, 0),
customText: 'JD',
},
{
@ -264,10 +273,7 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [
type: 'initials',
},
page: 1,
height: rowHeight,
width: columnWidth,
positionX: 0,
positionY: 0,
...calculatePositionPageOne(5, 1),
customText: 'JD',
},
{
@ -278,10 +284,7 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [
type: 'initials',
},
page: 1,
height: rowHeight,
width: columnWidth,
positionX: 0,
positionY: 0,
...calculatePositionPageOne(5, 2),
customText: 'JD',
},
/**
@ -299,10 +302,7 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [
],
},
page: 1,
height: rowHeight,
width: columnWidth,
positionX: 0,
positionY: 0,
...calculatePositionPageOne(6, 0),
customText: '0',
},
{
@ -312,15 +312,12 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [
type: 'radio',
values: [
{ id: 1, checked: false, value: 'Option 1' },
{ id: 2, checked: true, value: 'Option 2' },
{ id: 2, checked: false, value: 'Option 2' },
],
},
page: 1,
height: rowHeight,
width: columnWidth,
positionX: 0,
positionY: 0,
customText: '2',
...calculatePositionPageOne(6, 1),
customText: '',
},
{
type: FieldType.RADIO,
@ -330,15 +327,12 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [
type: 'radio',
values: [
{ id: 1, checked: false, value: 'Option 1' },
{ id: 2, checked: false, value: 'Option 2' },
{ id: 2, checked: true, value: 'Option 2' },
],
},
page: 1,
height: rowHeight,
width: columnWidth,
positionX: 0,
positionY: 0,
customText: '',
...calculatePositionPageOne(6, 2),
customText: '1',
},
/**
* Row 8 Checkbox
@ -355,10 +349,7 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [
],
},
page: 1,
height: rowHeight,
width: columnWidth,
positionX: 0,
positionY: 0,
...calculatePositionPageOne(7, 0),
customText: toCheckboxCustomText([0]),
},
{
@ -368,15 +359,12 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [
type: 'checkbox',
values: [
{ id: 1, checked: false, value: 'Option 1' },
{ id: 2, checked: true, value: 'Option 2' },
{ id: 2, checked: false, value: 'Option 2' },
],
},
page: 1,
height: rowHeight,
width: columnWidth,
positionX: 0,
positionY: 0,
customText: toCheckboxCustomText([1]),
...calculatePositionPageOne(7, 1),
customText: '',
},
{
type: FieldType.CHECKBOX,
@ -386,15 +374,12 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [
type: 'checkbox',
values: [
{ id: 1, checked: false, value: 'Option 1' },
{ id: 2, checked: false, value: 'Option 2' },
{ id: 2, checked: true, value: 'Option 2' },
],
},
page: 1,
height: rowHeight,
width: columnWidth,
positionX: 0,
positionY: 0,
customText: '',
...calculatePositionPageOne(7, 2),
customText: toCheckboxCustomText([1]),
},
/**
* Row 8 Dropdown
@ -407,10 +392,7 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [
type: 'dropdown',
},
page: 1,
height: rowHeight,
width: columnWidth,
positionX: 0,
positionY: 0,
...calculatePositionPageOne(8, 0),
customText: 'Option 1',
},
{
@ -420,10 +402,7 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [
type: 'dropdown',
},
page: 1,
height: rowHeight,
width: columnWidth,
positionX: 0,
positionY: 0,
...calculatePositionPageOne(8, 1),
customText: 'Option 1',
},
{
@ -434,10 +413,7 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [
type: 'dropdown',
},
page: 1,
height: rowHeight,
width: columnWidth,
positionX: 0,
positionY: 0,
...calculatePositionPageOne(8, 2),
customText: 'Option 1',
},
/**
@ -450,10 +426,7 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [
type: 'signature',
},
page: 1,
height: rowHeight,
width: columnWidth,
positionX: 0,
positionY: 0,
...calculatePositionPageOne(9, 0),
customText: '',
signature: 'My Signature',
},
@ -463,10 +436,7 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [
type: 'signature',
},
page: 1,
height: rowHeight,
width: columnWidth,
positionX: 0,
positionY: 0,
...calculatePositionPageOne(9, 1),
customText: '',
signature: 'My Signature',
},
@ -477,22 +447,295 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [
type: 'signature',
},
page: 1,
height: rowHeight,
width: columnWidth,
positionX: 0,
positionY: 0,
...calculatePositionPageOne(9, 2),
customText: '',
signature: 'My Signature',
},
/**
* @@@@@@@@@@@@@@@@@@@@@@@
*
* PAGE 2
*
* @@@@@@@@@@@@@@@@@@@@@@@
*/
// TEXT GRID ROW 1
{
type: FieldType.TEXT,
fieldMeta: {
textAlign: 'left',
type: 'text',
verticalAlign: 'top',
},
page: 2,
...calculatePositionPageTwo(0, 0),
customText: 'SOME TEXT',
},
{
type: FieldType.TEXT,
fieldMeta: {
textAlign: 'center',
type: 'text',
verticalAlign: 'top',
},
page: 2,
...calculatePositionPageTwo(0, 1),
customText: 'SOME TEXT',
},
{
type: FieldType.TEXT,
fieldMeta: {
textAlign: 'right',
type: 'text',
verticalAlign: 'top',
},
page: 2,
...calculatePositionPageTwo(0, 2),
customText: 'SOME TEXT',
},
// TEXT GRID ROW 2
{
type: FieldType.TEXT,
fieldMeta: {
textAlign: 'left',
type: 'text',
verticalAlign: 'middle',
},
page: 2,
...calculatePositionPageTwo(1, 0),
customText: 'SOME TEXT',
},
{
type: FieldType.TEXT,
fieldMeta: {
textAlign: 'center',
type: 'text',
verticalAlign: 'middle',
},
page: 2,
...calculatePositionPageTwo(1, 1),
customText: 'SOME TEXT',
},
{
type: FieldType.TEXT,
fieldMeta: {
textAlign: 'right',
type: 'text',
verticalAlign: 'middle',
},
page: 2,
...calculatePositionPageTwo(1, 2),
customText: 'SOME TEXT',
},
// TEXT GRID ROW 3
{
type: FieldType.TEXT,
fieldMeta: {
textAlign: 'left',
type: 'text',
verticalAlign: 'bottom',
},
page: 2,
...calculatePositionPageTwo(2, 0),
customText: 'SOME TEXT',
},
{
type: FieldType.TEXT,
fieldMeta: {
textAlign: 'center',
type: 'text',
verticalAlign: 'bottom',
},
page: 2,
...calculatePositionPageTwo(2, 1),
customText: 'SOME TEXT',
},
{
type: FieldType.TEXT,
fieldMeta: {
textAlign: 'right',
type: 'text',
verticalAlign: 'bottom',
},
page: 2,
...calculatePositionPageTwo(2, 2),
customText: 'SOME TEXT',
},
// NUMBER GRID ROW 1
{
type: FieldType.NUMBER,
fieldMeta: {
textAlign: 'left',
type: 'number',
verticalAlign: 'top',
},
page: 2,
...calculatePositionPageTwo(3, 0),
customText: '123456789123456789',
},
{
type: FieldType.NUMBER,
fieldMeta: {
textAlign: 'center',
type: 'number',
verticalAlign: 'top',
},
page: 2,
...calculatePositionPageTwo(3, 1),
customText: '123456789123456789',
},
{
type: FieldType.NUMBER,
fieldMeta: {
textAlign: 'right',
type: 'number',
verticalAlign: 'top',
},
page: 2,
...calculatePositionPageTwo(3, 2),
customText: '123456789123456789',
},
// NUMBER GRID ROW 2
{
type: FieldType.NUMBER,
fieldMeta: {
textAlign: 'left',
type: 'number',
verticalAlign: 'middle',
},
page: 2,
...calculatePositionPageTwo(4, 0),
customText: '123456789123456789',
},
{
type: FieldType.NUMBER,
fieldMeta: {
textAlign: 'center',
type: 'number',
verticalAlign: 'middle',
},
page: 2,
...calculatePositionPageTwo(4, 1),
customText: '123456789123456789',
},
{
type: FieldType.NUMBER,
fieldMeta: {
textAlign: 'right',
type: 'number',
verticalAlign: 'middle',
},
page: 2,
...calculatePositionPageTwo(4, 2),
customText: '123456789123456789',
},
// NUMBER GRID ROW 3
{
type: FieldType.NUMBER,
fieldMeta: {
textAlign: 'left',
type: 'number',
verticalAlign: 'bottom',
},
page: 2,
...calculatePositionPageTwo(5, 0),
customText: '123456789123456789',
},
{
type: FieldType.NUMBER,
fieldMeta: {
textAlign: 'center',
type: 'number',
verticalAlign: 'bottom',
},
page: 2,
...calculatePositionPageTwo(5, 1),
customText: '123456789123456789',
},
{
type: FieldType.NUMBER,
fieldMeta: {
textAlign: 'right',
type: 'number',
verticalAlign: 'bottom',
},
page: 2,
...calculatePositionPageTwo(5, 2),
customText: '123456789123456789',
},
// Text combing
{
type: FieldType.TEXT,
fieldMeta: {
type: 'text',
verticalAlign: 'middle',
letterSpacing: 32,
characterLimit: 9,
},
page: 2,
...calculatePositionPageTwo(6, 0, 'full'),
positionX: calculatePositionPageTwo(6, 0, 'full').positionX + 1.75,
width: calculatePositionPageTwo(6, 0, 'full').width + 1.75,
customText: 'HEY HEY 1',
},
// Number combing
{
type: FieldType.NUMBER,
fieldMeta: {
type: 'number',
verticalAlign: 'middle',
letterSpacing: 32,
},
page: 2,
...calculatePositionPageTwo(7, 0, 'full'),
positionX: calculatePositionPageTwo(7, 0, 'full').positionX + 1.75,
width: calculatePositionPageTwo(7, 0, 'full').width + 1.75,
customText: '123456789',
},
/**
* @@@@@@@@@@@@@@@@@@@@@@@
*
* PAGE 2 TEXT MULTILINE
*
* @@@@@@@@@@@@@@@@@@@@@@@
*/
{
type: FieldType.TEXT,
fieldMeta: {
verticalAlign: 'top',
textAlign: 'left',
lineHeight: 2.24,
type: 'text',
},
page: 3,
...calculatePositionPageThree(0, 0, 'full', 3),
customText:
'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.',
},
{
type: FieldType.TEXT,
fieldMeta: {
verticalAlign: 'middle',
textAlign: 'center',
lineHeight: 2.24,
type: 'text',
},
page: 3,
...calculatePositionPageThree(3, 0, 'full', 3),
customText:
'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.',
},
{
type: FieldType.TEXT,
fieldMeta: {
verticalAlign: 'bottom',
textAlign: 'right',
lineHeight: 2.24,
type: 'text',
},
page: 3,
...calculatePositionPageThree(6, 0, 'full', 3),
customText:
'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.',
},
] as const;
export const formatAlignmentTestFields = ALIGNMENT_TEST_FIELDS.map((field, index) => {
const row = Math.floor(index / 3);
const column = index % 3;
return {
...field,
positionX: alignmentGridStartX + column * columnWidth,
positionY: alignmentGridStartY + row * rowHeight,
};
});

View File

@ -7,6 +7,7 @@ import {
} from '@documenso/ui/primitives/document-flow/field-items-advanced-settings/constants';
import type { FieldTestData } from './field-alignment-pdf';
import { signatureBase64Demo } from './field-alignment-pdf';
const columnWidth = 20.1;
const fullColumnWidth = 75.8;
@ -37,7 +38,7 @@ export const FIELD_META_TEST_FIELDS: FieldTestData[] = [
page: 2,
...calculatePosition(0, 0),
customText: '',
signature: 'My Signature',
signature: signatureBase64Demo,
},
{
type: FieldType.SIGNATURE,
@ -47,7 +48,7 @@ export const FIELD_META_TEST_FIELDS: FieldTestData[] = [
page: 2,
...calculatePosition(1, 0),
customText: '',
signature: 'My Signature',
signature: signatureBase64Demo,
},
{
type: FieldType.SIGNATURE,
@ -67,7 +68,7 @@ export const FIELD_META_TEST_FIELDS: FieldTestData[] = [
page: 2,
...calculatePosition(3, 0),
customText: '',
signature: 'My Signature',
signature: 'My Signature super overflow maybe',
},
/**
@ -80,7 +81,7 @@ export const FIELD_META_TEST_FIELDS: FieldTestData[] = [
},
page: 3,
...calculatePosition(0, 0, 'full'),
customText: '123456789',
customText: 'Hello world, this is some random text that I have written here',
},
{
type: FieldType.TEXT,
@ -89,7 +90,7 @@ export const FIELD_META_TEST_FIELDS: FieldTestData[] = [
},
page: 3,
...calculatePosition(1, 0),
customText: '123456789123456789123456789123456789',
customText: 'Some text that should overflow correctly',
},
{
type: FieldType.TEXT,
@ -109,7 +110,7 @@ export const FIELD_META_TEST_FIELDS: FieldTestData[] = [
},
page: 3,
...calculatePosition(3, 0),
customText: '123456789',
customText: 'Input should have a placeholder text when clicked',
},
{
type: FieldType.TEXT,
@ -119,7 +120,7 @@ export const FIELD_META_TEST_FIELDS: FieldTestData[] = [
},
page: 3,
...calculatePosition(3, 1),
customText: '123456789',
customText: 'Should have a label during editing and signing',
},
{
type: FieldType.TEXT,
@ -129,7 +130,7 @@ export const FIELD_META_TEST_FIELDS: FieldTestData[] = [
},
page: 3,
...calculatePosition(3, 2),
customText: '123456789',
customText: '',
},
{
type: FieldType.TEXT,
@ -139,20 +140,19 @@ export const FIELD_META_TEST_FIELDS: FieldTestData[] = [
},
page: 3,
...calculatePosition(4, 0),
customText: '123456789',
customText: 'This is a required field',
},
{
type: FieldType.TEXT,
fieldMeta: {
type: 'text',
readOnly: true,
text: 'Readonly Value',
text: 'Some Readonly Value',
},
page: 3,
...calculatePosition(4, 1),
customText: 'Readonly Value',
customText: '',
},
/**
* PAGE 4 NUMBER
*/
@ -220,7 +220,7 @@ export const FIELD_META_TEST_FIELDS: FieldTestData[] = [
type: FieldType.NUMBER,
fieldMeta: {
type: 'number',
value: '123',
value: '123456789',
},
page: 4,
...calculatePosition(3, 2),
@ -241,10 +241,11 @@ export const FIELD_META_TEST_FIELDS: FieldTestData[] = [
fieldMeta: {
type: 'number',
readOnly: true,
value: '123456789',
},
page: 4,
...calculatePosition(4, 1),
customText: '123456789',
customText: '',
},
/**
@ -272,8 +273,8 @@ export const FIELD_META_TEST_FIELDS: FieldTestData[] = [
type: 'radio',
values: [
{ id: 1, checked: false, value: 'Option 1' },
{ id: 2, checked: true, value: 'Option 2' },
{ id: 3, checked: false, value: 'Option 3' },
{ id: 2, checked: false, value: 'Option 2' },
{ id: 3, checked: true, value: 'Option 3' },
],
},
page: 5,
@ -285,6 +286,7 @@ export const FIELD_META_TEST_FIELDS: FieldTestData[] = [
fieldMeta: {
direction: 'vertical',
type: 'radio',
required: true,
values: [
{ id: 1, checked: false, value: 'Option 1' },
{ id: 2, checked: false, value: 'Option 2' },
@ -293,17 +295,18 @@ export const FIELD_META_TEST_FIELDS: FieldTestData[] = [
},
page: 5,
...calculatePosition(2, 0),
customText: '',
customText: '2',
},
{
type: FieldType.RADIO,
fieldMeta: {
direction: 'vertical',
type: 'radio',
readOnly: true,
values: [
{ id: 1, checked: false, value: 'Option 1' },
{ id: 2, checked: false, value: 'Option 2' },
{ id: 3, checked: false, value: 'Option 3' },
{ id: 3, checked: true, value: 'Option 3' },
],
},
page: 5,
@ -338,7 +341,7 @@ export const FIELD_META_TEST_FIELDS: FieldTestData[] = [
values: [
{ id: 1, checked: false, value: 'Option 1' },
{ id: 2, checked: true, value: 'Option 2' },
{ id: 2, checked: true, value: 'Option 3' },
{ id: 3, checked: false, value: 'Option 3' },
],
},
page: 6,
@ -358,7 +361,7 @@ export const FIELD_META_TEST_FIELDS: FieldTestData[] = [
},
page: 6,
...calculatePosition(2, 0),
customText: '',
customText: toCheckboxCustomText([2]),
},
{
type: FieldType.CHECKBOX,
@ -368,7 +371,7 @@ export const FIELD_META_TEST_FIELDS: FieldTestData[] = [
readOnly: true,
values: [
{ id: 1, checked: false, value: 'Option 1' },
{ id: 2, checked: false, value: 'Option 2' },
{ id: 2, checked: true, value: 'Option 2' },
],
},
page: 6,
@ -445,11 +448,11 @@ export const FIELD_META_TEST_FIELDS: FieldTestData[] = [
fieldMeta: {
values: [{ value: 'Option 1' }, { value: 'Option 2' }],
type: 'dropdown',
defaultValue: 'Option 1',
defaultValue: 'Option 2',
},
page: 7,
...calculatePosition(1, 0),
customText: 'Option 1',
customText: 'Option 2',
},
{
type: FieldType.DROPDOWN,
@ -460,13 +463,14 @@ export const FIELD_META_TEST_FIELDS: FieldTestData[] = [
},
page: 7,
...calculatePosition(2, 0),
customText: 'Option 1',
customText: 'Option 3',
},
{
type: FieldType.DROPDOWN,
fieldMeta: {
values: [{ value: 'Option 1' }, { value: 'Option 2' }, { value: 'Option 3' }],
type: 'dropdown',
defaultValue: 'Option 1',
readOnly: true,
},
page: 7,

View File

@ -27,7 +27,7 @@ import type { TCreateEnvelopeRecipientsRequest } from '@documenso/trpc/server/en
import type { TGetEnvelopeResponse } from '@documenso/trpc/server/envelope-router/get-envelope.types';
import type { TUpdateEnvelopeRequest } from '@documenso/trpc/server/envelope-router/update-envelope.types';
import { formatAlignmentTestFields } from '../../../constants/field-alignment-pdf';
import { ALIGNMENT_TEST_FIELDS } from '../../../constants/field-alignment-pdf';
import { FIELD_META_TEST_FIELDS } from '../../../constants/field-meta-pdf';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
@ -490,7 +490,7 @@ test.describe('API V2 Envelopes', () => {
// Step 6: Create fields for first PDF (alignment fields)
const alignmentFieldsRequest = {
envelopeId: createdEnvelope.id,
data: formatAlignmentTestFields.map((field) => ({
data: ALIGNMENT_TEST_FIELDS.map((field) => ({
recipientId,
envelopeItemId: alignmentItem.id,
type: field.type,
@ -547,7 +547,7 @@ test.describe('API V2 Envelopes', () => {
expect(finalEnvelope.envelopeItems.length).toBe(2);
expect(finalEnvelope.recipients.length).toBe(1);
expect(finalEnvelope.fields.length).toBe(
formatAlignmentTestFields.length + FIELD_META_TEST_FIELDS.length,
ALIGNMENT_TEST_FIELDS.length + FIELD_META_TEST_FIELDS.length,
);
expect(finalEnvelope.title).toBe('Envelope Full Field Test');
expect(finalEnvelope.type).toBe(EnvelopeType.DOCUMENT);

View File

@ -21,7 +21,7 @@ import pixelMatch from 'pixelmatch';
import { PNG } from 'pngjs';
import type { TestInfo } from '@playwright/test';
import { expect, test } from '@playwright/test';
import { DocumentStatus } from '@prisma/client';
import { DocumentStatus, EnvelopeType } from '@prisma/client';
import fs from 'node:fs';
import path from 'node:path';
import * as pdfjsLib from 'pdfjs-dist/legacy/build/pdf.js';
@ -29,26 +29,218 @@ import { getEnvelopeItemPdfUrl } from '@documenso/lib/utils/envelope-download';
import { prisma } from '@documenso/prisma';
import { seedAlignmentTestDocument } from '@documenso/prisma/seed/initial-seed';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
import type {
TCreateEnvelopePayload,
TCreateEnvelopeResponse,
} from '../../../trpc/server/envelope-router/create-envelope.types';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../lib/constants/app';
import { createApiToken } from '../../../lib/server-only/public-api/create-api-token';
import { RecipientRole } from '../../../prisma/generated/types';
import { FIELD_META_TEST_FIELDS } from '../../constants/field-meta-pdf';
import { ALIGNMENT_TEST_FIELDS } from '../../constants/field-alignment-pdf';
import type { TDistributeEnvelopeRequest } from '../../../trpc/server/envelope-router/distribute-envelope.types';
import { isBase64Image } from '../../../lib/constants/signatures';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
const baseUrl = `${WEBAPP_BASE_URL}/api/v2`;
test.describe.configure({ mode: 'parallel', timeout: 60000 });
test.skip('field placement visual regression', async ({ page }, testInfo) => {
const { user, team } = await seedUser();
const envelope = await seedAlignmentTestDocument({
userId: user.id,
teamId: team.id,
recipientName: user.name || '',
recipientEmail: user.email,
insertFields: true,
status: DocumentStatus.PENDING,
test.skip('seed alignment test document', async ({ page }) => {
const user = await prisma.user.findFirstOrThrow({
where: {
email: 'example@documenso.com',
},
include: {
ownedOrganisations: {
include: {
teams: true,
},
},
},
});
const token = envelope.recipients[0].token;
const userId = user.id;
const teamId = user.ownedOrganisations[0].teams[0].id;
const signUrl = `/sign/${token}`;
await seedAlignmentTestDocument({
userId,
teamId,
recipientName: user.name || '',
recipientEmail: user.email,
insertFields: false,
status: DocumentStatus.DRAFT,
});
});
test('field placement visual regression', async ({ page, request }, testInfo) => {
const { user, team } = await seedUser();
const { token } = await createApiToken({
userId: user.id,
teamId: team.id,
tokenName: 'test',
expiresIn: null,
});
// Step 1: Create initial envelope with Prisma (with first envelope item)
const alignmentPdf = fs.readFileSync(
path.join(__dirname, '../../../../assets/field-font-alignment.pdf'),
);
const fieldMetaPdf = fs.readFileSync(path.join(__dirname, '../../../../assets/field-meta.pdf'));
const formData = new FormData();
const fieldMetaFields = FIELD_META_TEST_FIELDS.map((field) => ({
identifier: 'field-meta',
type: field.type,
page: field.page,
positionX: field.positionX,
positionY: field.positionY,
width: field.width,
height: field.height,
fieldMeta: field.fieldMeta,
}));
const alignmentFields = ALIGNMENT_TEST_FIELDS.map((field) => ({
identifier: 'alignment-pdf',
type: field.type,
page: field.page,
positionX: field.positionX,
positionY: field.positionY,
width: field.width,
height: field.height,
fieldMeta: field.fieldMeta,
}));
const createEnvelopePayload: TCreateEnvelopePayload = {
type: EnvelopeType.DOCUMENT,
title: 'Envelope Full Field Test',
recipients: [
{
email: user.email,
name: user.name || '',
role: RecipientRole.SIGNER,
fields: [...fieldMetaFields, ...alignmentFields],
},
],
};
formData.append('payload', JSON.stringify(createEnvelopePayload));
formData.append('files', new File([alignmentPdf], 'alignment-pdf', { type: 'application/pdf' }));
formData.append('files', new File([fieldMetaPdf], 'field-meta', { type: 'application/pdf' }));
const createEnvelopeRequest = await request.post(`${baseUrl}/envelope/create`, {
headers: { Authorization: `Bearer ${token}` },
multipart: formData,
});
expect(createEnvelopeRequest.ok()).toBeTruthy();
expect(createEnvelopeRequest.status()).toBe(200);
const { id: createdEnvelopeId }: TCreateEnvelopeResponse = await createEnvelopeRequest.json();
const envelope = await prisma.envelope.findUniqueOrThrow({
where: {
id: createdEnvelopeId,
},
include: {
recipients: true,
envelopeItems: true,
},
});
const recipientId = envelope.recipients[0].id;
const alignmentItem = envelope.envelopeItems.find((item: { order: number }) => item.order === 1);
const fieldMetaItem = envelope.envelopeItems.find((item: { order: number }) => item.order === 2);
expect(recipientId).toBeDefined();
expect(alignmentItem).toBeDefined();
expect(fieldMetaItem).toBeDefined();
if (!alignmentItem || !fieldMetaItem) {
throw new Error('Envelope items not found');
}
const distributeEnvelopeRequest = await request.post(`${baseUrl}/envelope/distribute`, {
headers: { Authorization: `Bearer ${token}` },
data: {
envelopeId: envelope.id,
} satisfies TDistributeEnvelopeRequest,
});
expect(distributeEnvelopeRequest.ok()).toBeTruthy();
const uninsertedFields = await prisma.field.findMany({
where: {
envelopeId: envelope.id,
inserted: false,
},
include: {
envelopeItem: {
select: {
title: true,
},
},
},
});
await Promise.all(
uninsertedFields.map(async (field) => {
let foundField = ALIGNMENT_TEST_FIELDS.find(
(f) =>
field.page === f.page &&
field.envelopeItem.title === 'alignment-pdf' &&
Number(field.positionX).toFixed(2) === f.positionX.toFixed(2) &&
Number(field.positionY).toFixed(2) === f.positionY.toFixed(2) &&
Number(field.width).toFixed(2) === f.width.toFixed(2) &&
Number(field.height).toFixed(2) === f.height.toFixed(2),
);
if (!foundField) {
foundField = FIELD_META_TEST_FIELDS.find(
(f) =>
field.page === f.page &&
field.envelopeItem.title === 'field-meta' &&
Number(field.positionX).toFixed(2) === f.positionX.toFixed(2) &&
Number(field.positionY).toFixed(2) === f.positionY.toFixed(2) &&
Number(field.width).toFixed(2) === f.width.toFixed(2) &&
Number(field.height).toFixed(2) === f.height.toFixed(2),
);
}
if (!foundField) {
throw new Error('Field not found');
}
await prisma.field.update({
where: {
id: field.id,
},
data: {
inserted: true,
customText: foundField.customText,
signature: foundField.signature
? {
create: {
recipientId: envelope.recipients[0].id,
signatureImageAsBase64: isBase64Image(foundField.signature)
? foundField.signature
: null,
typedSignature: isBase64Image(foundField.signature) ? null : foundField.signature,
},
}
: undefined,
},
});
}),
);
const recipientToken = envelope.recipients[0].token;
const signUrl = `/sign/${recipientToken}`;
await apiSignin({
page,
@ -97,7 +289,7 @@ test.skip('field placement visual regression', async ({ page }, testInfo) => {
const documentUrl = getEnvelopeItemPdfUrl({
type: 'download',
envelopeItem: item,
token,
token: recipientToken,
version: 'signed',
});
@ -289,7 +481,7 @@ const compareSignedPdfWithImages = async ({
// Expect the certificate to NOT be blank. Since the storedImage is blank.
expect.soft(comparison).toBeGreaterThan(20000);
} else {
expect.soft(comparison).toEqual(0);
expect.soft(comparison).toBeLessThan(2);
}
}
};

View File

@ -9,6 +9,7 @@ import { seedTeamMember } from '@documenso/prisma/seed/teams';
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
import { apiSignin } from '../fixtures/authentication';
import { expectTextToBeVisible } from '../fixtures/generic';
test.describe.configure({ mode: 'parallel' });
@ -81,20 +82,23 @@ test('[TEAMS]: can create a document inside a document folder', async ({ page })
redirectPath: `/t/${team.url}/documents/f/${teamFolder.id}`,
});
const fileInput = page.locator('input[type="file"]').nth(1);
await fileInput.waitFor({ state: 'attached' });
// Upload document.
const [fileChooser] = await Promise.all([
page.waitForEvent('filechooser'),
page.getByRole('button', { name: 'Document (Legacy)' }).click(),
]);
await fileInput.setInputFiles(
await fileChooser.setFiles(
path.join(__dirname, '../../../assets/documenso-supporter-pledge.pdf'),
);
await page.waitForTimeout(3000);
await expect(page.getByText('documenso-supporter-pledge.pdf')).toBeVisible();
await expectTextToBeVisible(page, 'documenso-supporter-pledge.pdf');
await page.goto(`/t/${team.url}/documents/f/${teamFolder.id}`);
await expect(page.getByText('documenso-supporter-pledge.pdf')).toBeVisible();
await expectTextToBeVisible(page, 'documenso-supporter-pledge.pdf');
});
test('[TEAMS]: can pin a document folder', async ({ page }) => {
@ -368,7 +372,7 @@ test('[TEAMS]: can create a template inside a template folder', async ({ page })
await expect(page.getByText('Team Client Templates')).toBeVisible();
await page.getByRole('button', { name: 'New Template' }).click();
await page.getByRole('button', { name: 'Template (Legacy)' }).click();
await page.getByText('Upload Template Document').click();
@ -382,11 +386,11 @@ test('[TEAMS]: can create a template inside a template folder', async ({ page })
await page.waitForTimeout(3000);
// Expect redirect.
await expect(page.getByText('documenso-supporter-pledge.pdf')).toBeVisible();
await expectTextToBeVisible(page, 'documenso-supporter-pledge.pdf');
// Return to folder and verify file is visible.
await page.goto(`/t/${team.url}/templates/f/${folder.id}`);
await expect(page.getByText('documenso-supporter-pledge.pdf')).toBeVisible();
await expectTextToBeVisible(page, 'documenso-supporter-pledge.pdf');
});
test('[TEAMS]: can pin a template folder', async ({ page }) => {
@ -842,7 +846,7 @@ test('[TEAMS]: documents inherit folder visibility', async ({ page }) => {
// Upload document.
const [fileChooser] = await Promise.all([
page.waitForEvent('filechooser'),
page.getByRole('button', { name: 'Upload Document' }).click(),
page.getByRole('button', { name: 'Document (Legacy)' }).click(),
]);
await fileChooser.setFiles(
@ -851,7 +855,7 @@ test('[TEAMS]: documents inherit folder visibility', async ({ page }) => {
await page.waitForTimeout(3000);
await expect(page.getByText('documenso-supporter-pledge.pdf')).toBeVisible();
await expectTextToBeVisible(page, 'documenso-supporter-pledge.pdf');
await expect(page.getByRole('combobox').filter({ hasText: 'Admins only' })).toBeVisible();
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

View File

@ -1,4 +1,4 @@
import { Trans } from '@lingui/react/macro';
import { Plural, Trans } from '@lingui/react/macro';
import { Heading, Img, Section, Text } from '../components';
@ -46,7 +46,11 @@ export const TemplateAccessAuth2FA = ({
</Section>
<Text className="mt-4 text-center text-sm text-slate-600">
<Trans>This code will expire in {expiresInMinutes} minutes.</Trans>
<Plural
value={expiresInMinutes}
one="This code will expire in # minute."
other="This code will expire in # minutes."
/>
</Text>
<Text className="mt-4 text-center text-sm text-slate-500">

View File

@ -11,7 +11,7 @@ export const validateNumberField = (
const { minValue, maxValue, readOnly, required, numberFormat, fontSize } = fieldMeta || {};
if (numberFormat) {
if (numberFormat && value.length > 0) {
const foundRegex = numberFormatValues.find((item) => item.value === numberFormat)?.regex;
if (!foundRegex) {

View File

@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import type { Recipient } from '@prisma/client';
import type { Field, Recipient } from '@prisma/client';
import { FieldType } from '@prisma/client';
import { useFieldArray, useForm } from 'react-hook-form';
import { z } from 'zod';
@ -63,6 +63,8 @@ type UseEditorFieldsResponse = {
// Selected recipient
selectedRecipient: Recipient | null;
setSelectedRecipient: (recipientId: number | null) => void;
resetForm: (fields?: Field[]) => void;
};
export const useEditorFields = ({
@ -72,24 +74,30 @@ export const useEditorFields = ({
const [selectedFieldFormId, setSelectedFieldFormId] = useState<string | null>(null);
const [selectedRecipientId, setSelectedRecipientId] = useState<number | null>(null);
const generateDefaultValues = (fields?: Field[]) => {
const formFields = (fields || envelope.fields).map(
(field): TLocalField => ({
id: field.id,
formId: nanoid(),
envelopeItemId: field.envelopeItemId,
page: field.page,
type: field.type,
positionX: Number(field.positionX),
positionY: Number(field.positionY),
width: Number(field.width),
height: Number(field.height),
recipientId: field.recipientId,
fieldMeta: field.fieldMeta ? ZFieldMetaSchema.parse(field.fieldMeta) : undefined,
}),
);
return {
fields: formFields,
};
};
const form = useForm<TEditorFieldsFormSchema>({
defaultValues: {
fields: envelope.fields.map(
(field): TLocalField => ({
id: field.id,
formId: nanoid(),
envelopeItemId: field.envelopeItemId,
page: field.page,
type: field.type,
positionX: Number(field.positionX),
positionY: Number(field.positionY),
width: Number(field.width),
height: Number(field.height),
recipientId: field.recipientId,
fieldMeta: field.fieldMeta ? ZFieldMetaSchema.parse(field.fieldMeta) : undefined,
}),
),
},
defaultValues: generateDefaultValues(),
resolver: zodResolver(ZEditorFieldsFormSchema),
});
@ -272,6 +280,10 @@ export const useEditorFields = ({
setSelectedRecipientId(foundRecipient?.id ?? null);
};
const resetForm = (fields?: Field[]) => {
form.reset(generateDefaultValues(fields));
};
return {
// Core state
localFields,
@ -295,6 +307,8 @@ export const useEditorFields = ({
// Selected recipient
selectedRecipient,
setSelectedRecipient,
resetForm,
};
};

View File

@ -107,6 +107,10 @@ export function usePageRenderer(renderFunction: RenderFunction) {
stage: stage.current,
pageLayer: pageLayer.current,
});
void document.fonts.ready.then(function () {
pageLayer.current?.batchDraw();
});
});
return () => {

View File

@ -30,6 +30,8 @@ type EnvelopeRenderItem = TEnvelope['envelopeItems'][number];
type EnvelopeRenderProviderValue = {
getPdfBuffer: (envelopeItemId: string) => FileData | null;
envelopeItems: EnvelopeRenderItem[];
envelopeStatus: TEnvelope['status'];
envelopeType: TEnvelope['type'];
currentEnvelopeItem: EnvelopeRenderItem | null;
setCurrentEnvelopeItem: (envelopeItemId: string) => void;
fields: Field[];
@ -44,7 +46,7 @@ type EnvelopeRenderProviderValue = {
interface EnvelopeRenderProviderProps {
children: React.ReactNode;
envelope: Pick<TEnvelope, 'envelopeItems'>;
envelope: Pick<TEnvelope, 'envelopeItems' | 'status' | 'type'>;
/**
* Optional fields which are passed down to renderers for custom rendering needs.
@ -100,7 +102,7 @@ export const EnvelopeRenderProvider = ({
// Indexed by documentDataId.
const [files, setFiles] = useState<Record<string, FileData>>({});
const [currentItem, setItem] = useState<EnvelopeRenderItem | null>(null);
const [currentItem, setCurrentItem] = useState<EnvelopeRenderItem | null>(null);
const [renderError, setRenderError] = useState<boolean>(false);
@ -163,11 +165,15 @@ export const EnvelopeRenderProvider = ({
const setCurrentEnvelopeItem = (envelopeItemId: string) => {
const foundItem = envelope.envelopeItems.find((item) => item.id === envelopeItemId);
setItem(foundItem ?? null);
setCurrentItem(foundItem ?? null);
};
// Set the selected item to the first item if none is set.
useEffect(() => {
if (currentItem && !envelopeItems.some((item) => item.id === currentItem.id)) {
setCurrentItem(null);
}
if (!currentItem && envelopeItems.length > 0) {
setCurrentEnvelopeItem(envelopeItems[0].id);
}
@ -203,6 +209,8 @@ export const EnvelopeRenderProvider = ({
value={{
getPdfBuffer,
envelopeItems,
envelopeStatus: envelope.status,
envelopeType: envelope.type,
currentEnvelopeItem: currentItem,
setCurrentEnvelopeItem,
fields: fields ?? [],

View File

@ -0,0 +1,110 @@
import type Konva from 'konva';
/**
* Represents canvas references for a specific PDF page.
*/
export interface PageCanvasRefs {
/** The page number (1-indexed) */
pageNumber: number;
/** The canvas element containing the rendered PDF */
pdfCanvas: HTMLCanvasElement;
/** The Konva stage containing field overlays */
konvaStage: Konva.Stage;
}
/**
* Module-level registry to store canvas references by page number.
* This allows any component to access page canvases without prop drilling.
*/
const pageCanvasRegistry = new Map<number, PageCanvasRefs>();
/**
* Register a page's canvas references.
* Call this when a page renderer mounts and has valid canvas refs.
*
* @param refs - The canvas references to register
*/
export const registerPageCanvas = (refs: PageCanvasRefs): void => {
pageCanvasRegistry.set(refs.pageNumber, refs);
};
/**
* Unregister a page's canvas references.
* Call this when a page renderer unmounts to prevent memory leaks.
*
* @param pageNumber - The page number to unregister
*/
export const unregisterPageCanvas = (pageNumber: number): void => {
pageCanvasRegistry.delete(pageNumber);
};
/**
* Get canvas references for a specific page.
*
* @param pageNumber - The page number to retrieve
* @returns The canvas references, or undefined if not registered
*/
export const getPageCanvasRefs = (pageNumber: number): PageCanvasRefs | undefined => {
return pageCanvasRegistry.get(pageNumber);
};
/**
* Get all registered page numbers.
*
* @returns Array of page numbers currently registered
*/
export const getRegisteredPageNumbers = (): number[] => {
return Array.from(pageCanvasRegistry.keys()).sort((a, b) => a - b);
};
/**
* Composite a PDF page with its field overlays into a single PNG Blob.
* This creates a temporary canvas, draws the PDF canvas first (background),
* then draws the Konva canvas on top (field overlays).
*
* @param pageNumber - The page number to composite (1-indexed)
* @returns Promise that resolves to a PNG Blob, or null if page not found or compositing fails
*/
export const compositePageToBlob = async (pageNumber: number): Promise<Blob | null> => {
const refs = getPageCanvasRefs(pageNumber);
if (!refs) {
console.warn(`Page ${pageNumber} is not registered for canvas capture`);
return null;
}
try {
// Create temporary canvas with same dimensions as PDF canvas
const tempCanvas = document.createElement('canvas');
tempCanvas.width = refs.pdfCanvas.width;
tempCanvas.height = refs.pdfCanvas.height;
const ctx = tempCanvas.getContext('2d');
if (!ctx) {
console.error('Failed to get 2D context for temporary canvas');
return null;
}
// Draw PDF canvas first (background layer)
ctx.drawImage(refs.pdfCanvas, 0, 0);
// Get Konva canvas and draw on top (field overlays)
// Note: Konva's toCanvas() returns a new canvas with all layers rendered
const konvaCanvas = refs.konvaStage.toCanvas();
ctx.drawImage(konvaCanvas, 0, 0);
// Convert to PNG Blob
return new Promise((resolve, reject) => {
tempCanvas.toBlob((blob) => {
if (blob) {
resolve(blob);
} else {
reject(new Error('Failed to convert canvas to blob'));
}
}, 'image/png');
});
} catch (error) {
console.error(`Error compositing page ${pageNumber}:`, error);
return null;
}
};

View File

@ -15,5 +15,3 @@ export const API_V2_BETA_URL = '/api/v2-beta';
export const API_V2_URL = '/api/v2';
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

@ -32,6 +32,7 @@ export type JobDefinition<Name extends string = string, Schema = any> = {
name: string;
version: string;
enabled?: boolean;
optimizeParallelism?: boolean;
trigger: {
name: Name;
schema?: z.ZodType<Schema>;

View File

@ -40,6 +40,7 @@ export class InngestJobProvider extends BaseJobProvider {
{
id: job.id,
name: job.name,
optimizeParallelism: job.optimizeParallelism ?? false,
},
{
event: job.trigger.name,

View File

@ -189,11 +189,44 @@ export const run = async ({
settings,
});
const decoratePromises: Array<Promise<{ oldDocumentDataId: string; newDocumentDataId: string }>> =
[];
// !: The commented out code is our desired implementation but we're seemingly
// !: running into issues with inngest parallelism in production.
// !: Until this is resolved we will do this sequentially which is slower but
// !: will actually work.
// const decoratePromises: Array<Promise<{ oldDocumentDataId: string; newDocumentDataId: string }>> =
// [];
// for (const envelopeItem of envelopeItems) {
// const task = io.runTask(`decorate-${envelopeItem.id}`, async () => {
// const envelopeItemFields = envelope.envelopeItems.find(
// (item) => item.id === envelopeItem.id,
// )?.field;
// if (!envelopeItemFields) {
// throw new Error(`Envelope item fields not found for envelope item ${envelopeItem.id}`);
// }
// return decorateAndSignPdf({
// envelope,
// envelopeItem,
// envelopeItemFields,
// isRejected,
// rejectionReason,
// certificateData,
// auditLogData,
// });
// });
// decoratePromises.push(task);
// }
// const newDocumentData = await Promise.all(decoratePromises);
// TODO: Remove once parallelization is working
const newDocumentData: Array<{ oldDocumentDataId: string; newDocumentDataId: string }> = [];
for (const envelopeItem of envelopeItems) {
const task = io.runTask(`decorate-${envelopeItem.id}`, async () => {
const result = await io.runTask(`decorate-${envelopeItem.id}`, async () => {
const envelopeItemFields = envelope.envelopeItems.find(
(item) => item.id === envelopeItem.id,
)?.field;
@ -213,11 +246,9 @@ export const run = async ({
});
});
decoratePromises.push(task);
newDocumentData.push(result);
}
const newDocumentData = await Promise.all(decoratePromises);
const postHog = PostHogServerClient();
if (postHog) {

View File

@ -18,6 +18,7 @@ export const SEAL_DOCUMENT_JOB_DEFINITION = {
id: SEAL_DOCUMENT_JOB_DEFINITION_ID,
name: 'Seal Document',
version: '1.0.0',
optimizeParallelism: true,
trigger: {
name: SEAL_DOCUMENT_JOB_DEFINITION_ID,
schema: SEAL_DOCUMENT_JOB_DEFINITION_SCHEMA,

View File

@ -43,6 +43,7 @@
"micro": "^10.0.1",
"nanoid": "^5.1.5",
"oslo": "^0.17.0",
"pdfjs-dist": "3.11.174",
"pg": "^8.11.3",
"pino": "^9.7.0",
"pino-pretty": "^13.0.0",

View File

@ -0,0 +1,363 @@
import type { DocumentStatus } from '@prisma/client';
import { EnvelopeType } from '@prisma/client';
import type { DateRange } from '@documenso/lib/types/search-params';
import { kyselyPrisma, sql } from '@documenso/prisma';
export type OrganisationSummary = {
totalTeams: number;
totalMembers: number;
totalDocuments: number;
activeDocuments: number;
completedDocuments: number;
volumeThisPeriod: number;
volumeAllTime: number;
};
export type OrganisationDetailedInsights = {
teams: TeamInsights[];
users: UserInsights[];
documents: DocumentInsights[];
totalPages: number;
summary?: OrganisationSummary;
};
export type TeamInsights = {
id: number;
name: string;
memberCount: number;
documentCount: number;
createdAt: Date;
};
export type UserInsights = {
id: number;
name: string;
email: string;
documentCount: number;
signedDocumentCount: number;
createdAt: Date;
};
export type DocumentInsights = {
id: string;
title: string;
status: DocumentStatus;
teamName: string;
createdAt: Date;
completedAt: Date | null;
};
export type GetOrganisationDetailedInsightsOptions = {
organisationId: string;
page?: number;
perPage?: number;
dateRange?: DateRange;
view: 'teams' | 'users' | 'documents';
};
export async function getOrganisationDetailedInsights({
organisationId,
page = 1,
perPage = 10,
dateRange = 'last30days',
view,
}: GetOrganisationDetailedInsightsOptions): Promise<OrganisationDetailedInsights> {
const offset = Math.max(page - 1, 0) * perPage;
const now = new Date();
let createdAtFrom: Date | null = null;
switch (dateRange) {
case 'last30days': {
createdAtFrom = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
break;
}
case 'last90days': {
createdAtFrom = new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000);
break;
}
case 'lastYear': {
createdAtFrom = new Date(now.getFullYear() - 1, now.getMonth(), now.getDate());
break;
}
case 'allTime':
default:
createdAtFrom = null;
break;
}
const summaryData = await getOrganisationSummary(organisationId, createdAtFrom);
const viewData = await (async () => {
switch (view) {
case 'teams':
return await getTeamInsights(organisationId, offset, perPage, createdAtFrom);
case 'users':
return await getUserInsights(organisationId, offset, perPage, createdAtFrom);
case 'documents':
return await getDocumentInsights(organisationId, offset, perPage, createdAtFrom);
default:
throw new Error(`Invalid view: ${view}`);
}
})();
return {
...viewData,
summary: summaryData,
};
}
async function getTeamInsights(
organisationId: string,
offset: number,
perPage: number,
createdAtFrom: Date | null,
): Promise<OrganisationDetailedInsights> {
const teamsQuery = kyselyPrisma.$kysely
.selectFrom('Team as t')
.leftJoin('Envelope as e', (join) =>
join
.onRef('t.id', '=', 'e.teamId')
.on('e.deletedAt', 'is', null)
.on('e.type', '=', sql.lit(EnvelopeType.DOCUMENT)),
)
.leftJoin('TeamGroup as tg', 'tg.teamId', 't.id')
.leftJoin('OrganisationGroup as og', 'og.id', 'tg.organisationGroupId')
.leftJoin('OrganisationGroupMember as ogm', 'ogm.groupId', 'og.id')
.leftJoin('OrganisationMember as om', 'om.id', 'ogm.organisationMemberId')
.where('t.organisationId', '=', organisationId)
.select([
't.id as id',
't.name as name',
't.createdAt as createdAt',
sql<number>`COUNT(DISTINCT om."userId")`.as('memberCount'),
(createdAtFrom
? sql<number>`COUNT(DISTINCT CASE WHEN e.id IS NOT NULL AND e."createdAt" >= ${createdAtFrom} THEN e.id END)`
: sql<number>`COUNT(DISTINCT e.id)`
).as('documentCount'),
])
.groupBy(['t.id', 't.name', 't.createdAt'])
.orderBy('documentCount', 'desc')
.limit(perPage)
.offset(offset);
const countQuery = kyselyPrisma.$kysely
.selectFrom('Team as t')
.where('t.organisationId', '=', organisationId)
.select(({ fn }) => [fn.countAll().as('count')]);
const [teams, countResult] = await Promise.all([teamsQuery.execute(), countQuery.execute()]);
const count = Number(countResult[0]?.count || 0);
return {
teams: teams as TeamInsights[],
users: [],
documents: [],
totalPages: Math.ceil(Number(count) / perPage),
};
}
async function getUserInsights(
organisationId: string,
offset: number,
perPage: number,
createdAtFrom: Date | null,
): Promise<OrganisationDetailedInsights> {
const usersBase = kyselyPrisma.$kysely
.selectFrom('OrganisationMember as om')
.innerJoin('User as u', 'u.id', 'om.userId')
.where('om.organisationId', '=', organisationId)
.leftJoin('Envelope as e', (join) =>
join
.onRef('e.userId', '=', 'u.id')
.on('e.deletedAt', 'is', null)
.on('e.type', '=', sql.lit(EnvelopeType.DOCUMENT)),
)
.leftJoin('Team as td', (join) =>
join.onRef('td.id', '=', 'e.teamId').on('td.organisationId', '=', organisationId),
)
.leftJoin('Recipient as r', (join) =>
join.onRef('r.email', '=', 'u.email').on('r.signedAt', 'is not', null),
)
.leftJoin('Envelope as se', (join) =>
join
.onRef('se.id', '=', 'r.envelopeId')
.on('se.deletedAt', 'is', null)
.on('se.type', '=', sql.lit(EnvelopeType.DOCUMENT)),
)
.leftJoin('Team as ts', (join) =>
join.onRef('ts.id', '=', 'se.teamId').on('ts.organisationId', '=', organisationId),
);
const usersQuery = usersBase
.select([
'u.id as id',
'u.name as name',
'u.email as email',
'u.createdAt as createdAt',
(createdAtFrom
? sql<number>`COUNT(DISTINCT CASE WHEN e.id IS NOT NULL AND td.id IS NOT NULL AND e."createdAt" >= ${createdAtFrom} THEN e.id END)`
: sql<number>`COUNT(DISTINCT CASE WHEN td.id IS NOT NULL THEN e.id END)`
).as('documentCount'),
(createdAtFrom
? sql<number>`COUNT(DISTINCT CASE WHEN e.id IS NOT NULL AND td.id IS NOT NULL AND e.status = 'COMPLETED' AND e."createdAt" >= ${createdAtFrom} THEN e.id END)`
: sql<number>`COUNT(DISTINCT CASE WHEN e.id IS NOT NULL AND td.id IS NOT NULL AND e.status = 'COMPLETED' THEN e.id END)`
).as('signedDocumentCount'),
])
.groupBy(['u.id', 'u.name', 'u.email', 'u.createdAt'])
.orderBy('u.createdAt', 'desc')
.limit(perPage)
.offset(offset);
const countQuery = kyselyPrisma.$kysely
.selectFrom('OrganisationMember as om')
.innerJoin('User as u', 'u.id', 'om.userId')
.where('om.organisationId', '=', organisationId)
.select(({ fn }) => [fn.countAll().as('count')]);
const [users, countResult] = await Promise.all([usersQuery.execute(), countQuery.execute()]);
const count = Number(countResult[0]?.count || 0);
return {
teams: [],
users: users as UserInsights[],
documents: [],
totalPages: Math.ceil(Number(count) / perPage),
};
}
async function getDocumentInsights(
organisationId: string,
offset: number,
perPage: number,
createdAtFrom: Date | null,
): Promise<OrganisationDetailedInsights> {
let documentsQuery = kyselyPrisma.$kysely
.selectFrom('Envelope as e')
.innerJoin('Team as t', 'e.teamId', 't.id')
.where('t.organisationId', '=', organisationId)
.where('e.deletedAt', 'is', null)
.where(() => sql`e.type = ${EnvelopeType.DOCUMENT}::"EnvelopeType"`);
if (createdAtFrom) {
documentsQuery = documentsQuery.where('e.createdAt', '>=', createdAtFrom);
}
documentsQuery = documentsQuery
.select([
'e.id as id',
'e.title as title',
'e.status as status',
'e.createdAt as createdAt',
'e.completedAt as completedAt',
't.name as teamName',
])
.orderBy('e.createdAt', 'desc')
.limit(perPage)
.offset(offset);
let countQuery = kyselyPrisma.$kysely
.selectFrom('Envelope as e')
.innerJoin('Team as t', 'e.teamId', 't.id')
.where('t.organisationId', '=', organisationId)
.where('e.deletedAt', 'is', null)
.where(() => sql`e.type = ${EnvelopeType.DOCUMENT}::"EnvelopeType"`);
if (createdAtFrom) {
countQuery = countQuery.where('e.createdAt', '>=', createdAtFrom);
}
countQuery = countQuery.select(({ fn }) => [fn.countAll().as('count')]);
const [documents, countResult] = await Promise.all([
documentsQuery.execute(),
countQuery.execute(),
]);
const count = Number((countResult[0] as { count: number })?.count || 0);
return {
teams: [],
users: [],
documents: documents.map((doc) => ({
...doc,
id: String((doc as { id: number }).id),
})) as DocumentInsights[],
totalPages: Math.ceil(Number(count) / perPage),
};
}
async function getOrganisationSummary(
organisationId: string,
createdAtFrom: Date | null,
): Promise<OrganisationSummary> {
const summaryQuery = kyselyPrisma.$kysely
.selectFrom('Organisation as o')
.where('o.id', '=', organisationId)
.select([
sql<number>`(SELECT COUNT(DISTINCT t2.id) FROM "Team" AS t2 WHERE t2."organisationId" = o.id)`.as(
'totalTeams',
),
sql<number>`(SELECT COUNT(DISTINCT om2."userId") FROM "OrganisationMember" AS om2 WHERE om2."organisationId" = o.id)`.as(
'totalMembers',
),
sql<number>`(
SELECT COUNT(DISTINCT e2.id)
FROM "Envelope" AS e2
INNER JOIN "Team" AS t2 ON t2.id = e2."teamId"
WHERE t2."organisationId" = o.id AND e2."deletedAt" IS NULL AND e2.type = 'DOCUMENT'
)`.as('totalDocuments'),
sql<number>`(
SELECT COUNT(DISTINCT e2.id)
FROM "Envelope" AS e2
INNER JOIN "Team" AS t2 ON t2.id = e2."teamId"
WHERE t2."organisationId" = o.id AND e2."deletedAt" IS NULL AND e2.type = 'DOCUMENT' AND e2.status IN ('DRAFT', 'PENDING')
)`.as('activeDocuments'),
sql<number>`(
SELECT COUNT(DISTINCT e2.id)
FROM "Envelope" AS e2
INNER JOIN "Team" AS t2 ON t2.id = e2."teamId"
WHERE t2."organisationId" = o.id AND e2."deletedAt" IS NULL AND e2.type = 'DOCUMENT' AND e2.status = 'COMPLETED'
)`.as('completedDocuments'),
(createdAtFrom
? sql<number>`(
SELECT COUNT(DISTINCT e2.id)
FROM "Envelope" AS e2
INNER JOIN "Team" AS t2 ON t2.id = e2."teamId"
WHERE t2."organisationId" = o.id
AND e2."deletedAt" IS NULL
AND e2.type = 'DOCUMENT'
AND e2.status = 'COMPLETED'
AND e2."createdAt" >= ${createdAtFrom}
)`
: sql<number>`(
SELECT COUNT(DISTINCT e2.id)
FROM "Envelope" AS e2
INNER JOIN "Team" AS t2 ON t2.id = e2."teamId"
WHERE t2."organisationId" = o.id
AND e2."deletedAt" IS NULL
AND e2.type = 'DOCUMENT'
AND e2.status = 'COMPLETED'
)`
).as('volumeThisPeriod'),
sql<number>`(
SELECT COUNT(DISTINCT e2.id)
FROM "Envelope" AS e2
INNER JOIN "Team" AS t2 ON t2.id = e2."teamId"
WHERE t2."organisationId" = o.id AND e2."deletedAt" IS NULL AND e2.type = 'DOCUMENT' AND e2.status = 'COMPLETED'
)`.as('volumeAllTime'),
]);
const result = await summaryQuery.executeTakeFirst();
return {
totalTeams: Number(result?.totalTeams || 0),
totalMembers: Number(result?.totalMembers || 0),
totalDocuments: Number(result?.totalDocuments || 0),
activeDocuments: Number(result?.activeDocuments || 0),
completedDocuments: Number(result?.completedDocuments || 0),
volumeThisPeriod: Number(result?.volumeThisPeriod || 0),
volumeAllTime: Number(result?.volumeAllTime || 0),
};
}

View File

@ -1,13 +1,17 @@
import { DocumentStatus, EnvelopeType, SubscriptionStatus } from '@prisma/client';
import { DocumentStatus, EnvelopeType } from '@prisma/client';
import type { DateRange } from '@documenso/lib/types/search-params';
import { kyselyPrisma, sql } from '@documenso/prisma';
export type SigningVolume = {
export type OrganisationInsights = {
id: number;
name: string;
signingVolume: number;
createdAt: Date;
planId: string;
customerId: string | null;
subscriptionStatus?: string;
teamCount?: number;
memberCount?: number;
};
export type GetSigningVolumeOptions = {
@ -28,28 +32,26 @@ export async function getSigningVolume({
const offset = Math.max(page - 1, 0) * perPage;
let findQuery = kyselyPrisma.$kysely
.selectFrom('Subscription as s')
.innerJoin('Organisation as o', 's.organisationId', 'o.id')
.selectFrom('Organisation as o')
.leftJoin('Team as t', 'o.id', 't.organisationId')
.leftJoin('Envelope as e', (join) =>
join
.onRef('t.id', '=', 'e.teamId')
.on('e.status', '=', sql.lit(DocumentStatus.COMPLETED))
.on('e.deletedAt', 'is', null),
.on('e.deletedAt', 'is', null)
.on('e.type', '=', sql.lit(EnvelopeType.DOCUMENT)),
)
.where(sql`s.status = ${SubscriptionStatus.ACTIVE}::"SubscriptionStatus"`)
.where((eb) =>
eb.or([eb('o.name', 'ilike', `%${search}%`), eb('t.name', 'ilike', `%${search}%`)]),
)
.where('e.type', '=', EnvelopeType.DOCUMENT)
.select([
's.id as id',
's.createdAt as createdAt',
's.planId as planId',
'o.id as id',
'o.createdAt as createdAt',
'o.customerId as customerId',
sql<string>`COALESCE(o.name, 'Unknown')`.as('name'),
sql<number>`COUNT(DISTINCT e.id)`.as('signingVolume'),
])
.groupBy(['s.id', 'o.name']);
.groupBy(['o.id', 'o.name', 'o.customerId']);
switch (sortBy) {
case 'name':
@ -68,19 +70,127 @@ export async function getSigningVolume({
findQuery = findQuery.limit(perPage).offset(offset);
const countQuery = kyselyPrisma.$kysely
.selectFrom('Subscription as s')
.innerJoin('Organisation as o', 's.organisationId', 'o.id')
.selectFrom('Organisation as o')
.leftJoin('Team as t', 'o.id', 't.organisationId')
.where(sql`s.status = ${SubscriptionStatus.ACTIVE}::"SubscriptionStatus"`)
.where((eb) =>
eb.or([eb('o.name', 'ilike', `%${search}%`), eb('t.name', 'ilike', `%${search}%`)]),
)
.select(({ fn }) => [fn.countAll().as('count')]);
.select(() => [sql<number>`COUNT(DISTINCT o.id)`.as('count')]);
const [results, [{ count }]] = await Promise.all([findQuery.execute(), countQuery.execute()]);
return {
leaderboard: results,
organisations: results,
totalPages: Math.ceil(Number(count) / perPage),
};
}
export type GetOrganisationInsightsOptions = GetSigningVolumeOptions & {
dateRange?: DateRange;
startDate?: Date;
endDate?: Date;
};
export async function getOrganisationInsights({
search = '',
page = 1,
perPage = 10,
sortBy = 'signingVolume',
sortOrder = 'desc',
dateRange = 'last30days',
startDate,
endDate,
}: GetOrganisationInsightsOptions) {
const offset = Math.max(page - 1, 0) * perPage;
const now = new Date();
let dateCondition = sql`1=1`;
if (startDate && endDate) {
dateCondition = sql`e."createdAt" >= ${startDate} AND e."createdAt" <= ${endDate}`;
} else {
switch (dateRange) {
case 'last30days': {
const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
dateCondition = sql`e."createdAt" >= ${thirtyDaysAgo}`;
break;
}
case 'last90days': {
const ninetyDaysAgo = new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000);
dateCondition = sql`e."createdAt" >= ${ninetyDaysAgo}`;
break;
}
case 'lastYear': {
const oneYearAgo = new Date(now.getFullYear() - 1, now.getMonth(), now.getDate());
dateCondition = sql`e."createdAt" >= ${oneYearAgo}`;
break;
}
case 'allTime':
default:
dateCondition = sql`1=1`;
break;
}
}
let findQuery = kyselyPrisma.$kysely
.selectFrom('Organisation as o')
.leftJoin('Team as t', 'o.id', 't.organisationId')
.leftJoin('Envelope as e', (join) =>
join
.onRef('t.id', '=', 'e.teamId')
.on('e.status', '=', sql.lit(DocumentStatus.COMPLETED))
.on('e.deletedAt', 'is', null)
.on('e.type', '=', sql.lit(EnvelopeType.DOCUMENT)),
)
.leftJoin('OrganisationMember as om', 'o.id', 'om.organisationId')
.leftJoin('Subscription as s', 'o.id', 's.organisationId')
.where((eb) =>
eb.or([eb('o.name', 'ilike', `%${search}%`), eb('t.name', 'ilike', `%${search}%`)]),
)
.select([
'o.id as id',
'o.createdAt as createdAt',
'o.customerId as customerId',
sql<string>`COALESCE(o.name, 'Unknown')`.as('name'),
sql<number>`COUNT(DISTINCT CASE WHEN e.id IS NOT NULL AND ${dateCondition} THEN e.id END)`.as(
'signingVolume',
),
sql<number>`GREATEST(COUNT(DISTINCT t.id), 1)`.as('teamCount'),
sql<number>`COUNT(DISTINCT om."userId")`.as('memberCount'),
sql<string>`CASE WHEN s.status IS NOT NULL THEN s.status ELSE NULL END`.as(
'subscriptionStatus',
),
])
.groupBy(['o.id', 'o.name', 'o.customerId', 's.status']);
switch (sortBy) {
case 'name':
findQuery = findQuery.orderBy('name', sortOrder);
break;
case 'createdAt':
findQuery = findQuery.orderBy('createdAt', sortOrder);
break;
case 'signingVolume':
findQuery = findQuery.orderBy('signingVolume', sortOrder);
break;
default:
findQuery = findQuery.orderBy('signingVolume', 'desc');
}
findQuery = findQuery.limit(perPage).offset(offset);
const countQuery = kyselyPrisma.$kysely
.selectFrom('Organisation as o')
.leftJoin('Team as t', 'o.id', 't.organisationId')
.where((eb) =>
eb.or([eb('o.name', 'ilike', `%${search}%`), eb('t.name', 'ilike', `%${search}%`)]),
)
.select(() => [sql<number>`COUNT(DISTINCT o.id)`.as('count')]);
const [results, [{ count }]] = await Promise.all([findQuery.execute(), countQuery.execute()]);
return {
organisations: results,
totalPages: Math.ceil(Number(count) / perPage),
};
}

View File

@ -19,7 +19,7 @@ import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { jobs } from '../../jobs/client';
import type { TRecipientAccessAuth, TRecipientActionAuth } from '../../types/document-auth';
import type { TRecipientAccessAuth } from '../../types/document-auth';
import { DocumentAuth } from '../../types/document-auth';
import {
ZWebhookDocumentSchema,
@ -37,7 +37,6 @@ export type CompleteDocumentWithTokenOptions = {
token: string;
id: EnvelopeIdOptions;
userId?: number;
authOptions?: TRecipientActionAuth;
accessAuthOptions?: TRecipientAccessAuth;
requestMetadata?: RequestMetadata;
nextSigner?: {

View File

@ -1,4 +1,4 @@
import type { DocumentData, Envelope, EnvelopeItem } from '@prisma/client';
import type { DocumentData, Envelope, EnvelopeItem, Field } from '@prisma/client';
import {
DocumentSigningOrder,
DocumentStatus,
@ -24,7 +24,9 @@ import {
ZCheckboxFieldMeta,
ZDropdownFieldMeta,
ZFieldAndMetaSchema,
ZNumberFieldMeta,
ZRadioFieldMeta,
ZTextFieldMeta,
} from '../../types/field-meta';
import {
ZWebhookDocumentSchema,
@ -182,80 +184,19 @@ export const sendDocument = async ({
// Validate and autoinsert fields for V2 envelopes.
if (envelope.internalVersion === 2) {
for (const unknownField of envelope.fields) {
const parsedField = ZFieldAndMetaSchema.safeParse(unknownField);
const recipient = envelope.recipients.find((r) => r.id === unknownField.recipientId);
if (parsedField.error) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'One or more fields have invalid metadata. Error: ' + parsedField.error.message,
if (!recipient) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Recipient not found',
});
}
const field = parsedField.data;
const fieldId = unknownField.id;
const fieldToAutoInsert = extractFieldAutoInsertValues(unknownField);
if (field.type === FieldType.RADIO) {
const { values = [] } = ZRadioFieldMeta.parse(field.fieldMeta);
const checkedItemIndex = values.findIndex((value) => value.checked);
if (checkedItemIndex !== -1) {
fieldsToAutoInsert.push({
fieldId,
customText: toRadioCustomText(checkedItemIndex),
});
}
}
if (field.type === FieldType.DROPDOWN) {
const { defaultValue, values = [] } = ZDropdownFieldMeta.parse(field.fieldMeta);
if (defaultValue && values.some((value) => value.value === defaultValue)) {
fieldsToAutoInsert.push({
fieldId,
customText: defaultValue,
});
}
}
if (field.type === FieldType.CHECKBOX) {
const {
values = [],
validationRule,
validationLength,
} = ZCheckboxFieldMeta.parse(field.fieldMeta);
const checkedIndices: number[] = [];
values.forEach((value, i) => {
if (value.checked) {
checkedIndices.push(i);
}
});
let isValid = true;
if (validationRule && validationLength) {
const validation = checkboxValidationSigns.find((sign) => sign.label === validationRule);
if (!validation) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Invalid checkbox validation rule',
});
}
isValid = validateCheckboxLength(
checkedIndices.length,
validation.value,
validationLength,
);
}
if (isValid && checkedIndices.length > 0) {
fieldsToAutoInsert.push({
fieldId,
customText: toCheckboxCustomText(checkedIndices),
});
}
// Only auto-insert fields if the recipient has not been sent the document yet.
if (fieldToAutoInsert && recipient.sendStatus !== SendStatus.SENT) {
fieldsToAutoInsert.push(fieldToAutoInsert);
}
}
}
@ -275,6 +216,7 @@ export const sendDocument = async ({
if (envelope.internalVersion === 2) {
const autoInsertedFields = await Promise.all(
fieldsToAutoInsert.map(async (field) => {
// Warning: Only auto-insert fields if the recipient has not been sent the document yet.
return await tx.field.update({
where: {
id: field.fieldId,
@ -387,3 +329,113 @@ const injectFormValuesIntoDocument = async (
},
});
};
/**
* Extracts the auto insertion values for a given field.
*
* If field is not auto insertable, returns `null`.
*/
export const extractFieldAutoInsertValues = (
unknownField: Field,
): { fieldId: number; customText: string } | null => {
const parsedField = ZFieldAndMetaSchema.safeParse(unknownField);
if (parsedField.error) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'One or more fields have invalid metadata. Error: ' + parsedField.error.message,
});
}
const field = parsedField.data;
const fieldId = unknownField.id;
// Auto insert text fields with prefilled values.
if (field.type === FieldType.TEXT) {
const { text } = ZTextFieldMeta.parse(field.fieldMeta);
if (text) {
return {
fieldId,
customText: text,
};
}
}
// Auto insert number fields with prefilled values.
if (field.type === FieldType.NUMBER) {
const { value } = ZNumberFieldMeta.parse(field.fieldMeta);
if (value) {
return {
fieldId,
customText: value,
};
}
}
// Auto insert radio fields with the pre-checked value.
if (field.type === FieldType.RADIO) {
const { values = [] } = ZRadioFieldMeta.parse(field.fieldMeta);
const checkedItemIndex = values.findIndex((value) => value.checked);
if (checkedItemIndex !== -1) {
return {
fieldId,
customText: toRadioCustomText(checkedItemIndex),
};
}
}
// Auto insert dropdown fields with the default value.
if (field.type === FieldType.DROPDOWN) {
const { defaultValue, values = [] } = ZDropdownFieldMeta.parse(field.fieldMeta);
if (defaultValue && values.some((value) => value.value === defaultValue)) {
return {
fieldId,
customText: defaultValue,
};
}
}
// Auto insert checkbox fields with the pre-checked values.
if (field.type === FieldType.CHECKBOX) {
const {
values = [],
validationRule,
validationLength,
} = ZCheckboxFieldMeta.parse(field.fieldMeta);
const checkedIndices: number[] = [];
values.forEach((value, i) => {
if (value.checked) {
checkedIndices.push(i);
}
});
let isValid = true;
if (validationRule && validationLength) {
const validation = checkboxValidationSigns.find((sign) => sign.label === validationRule);
if (!validation) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Invalid checkbox validation rule',
});
}
isValid = validateCheckboxLength(checkedIndices.length, validation.value, validationLength);
}
if (isValid && checkedIndices.length > 0) {
return {
fieldId,
customText: toCheckboxCustomText(checkedIndices),
};
}
}
return null;
};

View File

@ -6,6 +6,7 @@ import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { DocumentAccessAuth, type TDocumentAuthMethods } from '../../types/document-auth';
import { extractDocumentAuthMethods } from '../../utils/document-auth';
import { extractFieldAutoInsertValues } from '../document/send-document';
import { getTeamSettings } from '../team/get-team-settings';
import type { EnvelopeForSigningResponse } from './get-envelope-for-recipient-signing';
import { ZEnvelopeForSigningResponse } from './get-envelope-for-recipient-signing';
@ -144,6 +145,19 @@ export const getEnvelopeForDirectTemplateSigning = async ({
recipient: {
...recipient,
directToken: envelope.directLink?.token || '',
fields: recipient.fields.map((field) => {
const autoInsertValue = extractFieldAutoInsertValues(field);
if (!autoInsertValue) {
return field;
}
return {
...field,
inserted: true,
customText: autoInsertValue.customText,
};
}),
},
recipientSignature: null,
isRecipientsTurn: true,

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