Compare commits

..

6 Commits

Author SHA1 Message Date
18ad50bf5a docs: add missing environment variables to self-hosting guide 2025-11-20 08:38:54 +00:00
11d9bde8f8 fix: improve sealing speed (#2210) 2025-11-19 14:15:12 +11:00
fa1680aaf1 v2.0.13 2025-11-18 16:59:02 +11:00
798b6bd750 feat: add japanese chinese and korean support (#2202)
## Description

Adds the following languages since we updated our PDF sealing to support
special characters
- Japanese
- Korean
- Chinese (Simplified)

## Tests

Ran through the signing process in these new languages.
2025-11-18 16:57:38 +11:00
8fbace0f61 fix: viewed webhook had stale data (#2208) 2025-11-18 16:57:14 +11:00
1bbd04be9b feat: add field dev mode (#2203) 2025-11-18 16:57:06 +11:00
10 changed files with 189 additions and 199 deletions

View File

@ -275,7 +275,15 @@ The environment variables listed above are a subset of those available for confi
| `NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY` | The secondary encryption key for symmetric encryption and decryption (at least 32 characters). |
| `NEXT_PRIVATE_GOOGLE_CLIENT_ID` | The Google client ID for Google authentication (optional). |
| `NEXT_PRIVATE_GOOGLE_CLIENT_SECRET` | The Google client secret for Google authentication (optional). |
| `NEXT_PRIVATE_MICROSOFT_CLIENT_ID` | The Microsoft client ID for Microsoft authentication (optional). |
| `NEXT_PRIVATE_MICROSOFT_CLIENT_SECRET` | The Microsoft client secret for Microsoft authentication (optional). |
| `NEXT_PRIVATE_OIDC_CLIENT_ID` | The OIDC client ID for OIDC authentication (optional). |
| `NEXT_PRIVATE_OIDC_CLIENT_SECRET` | The OIDC client secret for OIDC authentication (optional). |
| `NEXT_PRIVATE_OIDC_WELL_KNOWN` | The well-known URL for the OIDC provider (optional). |
| `NEXT_PRIVATE_OIDC_PROVIDER_LABEL` | The label to display for the OIDC provider button (optional). |
| `NEXT_PRIVATE_OIDC_SKIP_VERIFY` | Whether to skip email verification for OIDC accounts (optional, default `false`). |
| `NEXT_PUBLIC_WEBAPP_URL` | The URL for the web application. |
| `NEXT_PUBLIC_SUPPORT_EMAIL` | The support email address displayed to users (default `support@documenso.com`). |
| `NEXT_PRIVATE_DATABASE_URL` | The URL for the primary database connection (with connection pooling). |
| `NEXT_PRIVATE_DIRECT_DATABASE_URL` | The URL for the direct database connection (without connection pooling). |
| `NEXT_PRIVATE_SIGNING_TRANSPORT` | The signing transport to use. Available options: local (default) |
@ -297,6 +305,7 @@ The environment variables listed above are a subset of those available for confi
| `NEXT_PRIVATE_SMTP_APIKEY_USER` | The API key user for the SMTP server for the `smtp-api` transport. |
| `NEXT_PRIVATE_SMTP_APIKEY` | The API key for the SMTP server for the `smtp-api` transport. |
| `NEXT_PRIVATE_SMTP_SECURE` | Whether to force the use of TLS for the SMTP server for SMTP transports. |
| `NEXT_PRIVATE_SMTP_UNSAFE_IGNORE_TLS` | Whether to ignore TLS errors for the SMTP server (useful for self-signed certificates). |
| `NEXT_PRIVATE_SMTP_FROM_ADDRESS` | The email address for the "from" address. |
| `NEXT_PRIVATE_SMTP_FROM_NAME` | The sender name for the "from" address. |
| `NEXT_PRIVATE_RESEND_API_KEY` | The API key for Resend.com for the `resend` transport. |
@ -308,6 +317,7 @@ The environment variables listed above are a subset of those available for confi
| `NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT` | The maximum document upload limit displayed to the user (in MB). |
| `NEXT_PUBLIC_POSTHOG_KEY` | The optional PostHog key for analytics and feature flags. |
| `NEXT_PUBLIC_DISABLE_SIGNUP` | Whether to disable user signups through the /signup page. |
| `NEXT_PRIVATE_BROWSERLESS_URL` | The URL for a Browserless.io instance to generate PDFs (optional). |
## Run as a Service

View File

@ -5,7 +5,7 @@ import { msg } from '@lingui/core/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import { FieldType, RecipientRole } from '@prisma/client';
import { FileTextIcon } from 'lucide-react';
import { Link } from 'react-router';
import { Link, useSearchParams } from 'react-router';
import { isDeepEqual } from 'remeda';
import { match } from 'ts-pattern';
@ -65,6 +65,8 @@ const FieldSettingsTypeTranslations: Record<FieldType, MessageDescriptor> = {
};
export const EnvelopeEditorFieldsPage = () => {
const [searchParams] = useSearchParams();
const { envelope, editorFields, relativePath } = useCurrentEnvelopeEditor();
const { currentEnvelopeItem } = useCurrentEnvelopeRender();
@ -208,6 +210,37 @@ export const EnvelopeEditorFieldsPage = () => {
<section>
<Separator className="my-4" />
{searchParams.get('devmode') && (
<>
<div className="px-4">
<h3 className="text-foreground mb-3 text-sm font-semibold">
<Trans>Developer Mode</Trans>
</h3>
<div className="bg-muted/50 border-border text-foreground space-y-2 rounded-md border p-3 text-sm">
<p>
<span className="text-muted-foreground min-w-12">Pos X:&nbsp;</span>
{selectedField.positionX.toFixed(2)}
</p>
<p>
<span className="text-muted-foreground min-w-12">Pos Y:&nbsp;</span>
{selectedField.positionY.toFixed(2)}
</p>
<p>
<span className="text-muted-foreground min-w-12">Width:&nbsp;</span>
{selectedField.width.toFixed(2)}
</p>
<p>
<span className="text-muted-foreground min-w-12">Height:&nbsp;</span>
{selectedField.height.toFixed(2)}
</p>
</div>
</div>
<Separator className="my-4" />
</>
)}
<div className="[&_label]:text-foreground/70 px-4 [&_label]:text-xs">
<h3 className="text-sm font-semibold">
{t(FieldSettingsTypeTranslations[selectedField.type])}

View File

@ -44,7 +44,7 @@ export default function EnvelopeEditorHeader() {
<nav className="bg-background border-border w-full border-b px-4 py-3 md:px-6">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4">
<Link to={relativePath.basePath}>
<Link to="/">
<BrandingLogo className="h-6 w-auto" />
</Link>
<Separator orientation="vertical" className="h-6" />

View File

@ -22,14 +22,12 @@ import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounce
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { isTemplateRecipientEmailPlaceholder } from '@documenso/lib/constants/template';
import {
ZRecipientActionAuthTypesSchema,
ZRecipientAuthOptionsSchema,
} from '@documenso/lib/types/document-auth';
import { nanoid } from '@documenso/lib/universal/id';
import { canRecipientBeModified as utilCanRecipientBeModified } from '@documenso/lib/utils/recipients';
import { generateRecipientPlaceholder } from '@documenso/lib/utils/templates';
import { trpc } from '@documenso/trpc/react';
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
import { RecipientActionAuthSelect } from '@documenso/ui/components/recipient/recipient-action-auth-select';
@ -84,8 +82,7 @@ const ZEnvelopeRecipientsForm = z.object({
type TEnvelopeRecipientsForm = z.infer<typeof ZEnvelopeRecipientsForm>;
export const EnvelopeEditorRecipientForm = () => {
const { envelope, setRecipientsDebounced, updateEnvelope, isTemplate } =
useCurrentEnvelopeEditor();
const { envelope, setRecipientsDebounced, updateEnvelope } = useCurrentEnvelopeEditor();
const organisation = useCurrentOrganisation();
@ -122,7 +119,6 @@ export const EnvelopeEditorRecipientForm = () => {
role: RecipientRole.SIGNER,
signingOrder: 1,
actionAuth: [],
...(isTemplate ? generateRecipientPlaceholder(1) : {}),
},
];
@ -238,8 +234,6 @@ export const EnvelopeEditorRecipientForm = () => {
};
const onAddSigner = () => {
const placeholderRecipientCount = signers.length > 1 ? signers.length + 1 : 2;
appendSigner({
formId: nanoid(12),
name: '',
@ -247,10 +241,7 @@ export const EnvelopeEditorRecipientForm = () => {
role: RecipientRole.SIGNER,
actionAuth: [],
signingOrder: signers.length > 0 ? (signers[signers.length - 1]?.signingOrder ?? 0) + 1 : 1,
...(isTemplate ? generateRecipientPlaceholder(placeholderRecipientCount) : {}),
});
void form.trigger('signers');
};
const onRemoveSigner = (index: number) => {
@ -815,7 +806,7 @@ export const EnvelopeEditorRecipientForm = () => {
})}
>
{!showAdvancedSettings && index === 0 && (
<FormLabel required={!isTemplate}>
<FormLabel required>
<Trans>Email</Trans>
</FormLabel>
)}
@ -824,12 +815,7 @@ export const EnvelopeEditorRecipientForm = () => {
<RecipientAutoCompleteInput
type="email"
placeholder={t`Email`}
value={
isTemplate &&
isTemplateRecipientEmailPlaceholder(field.value)
? ''
: field.value
}
value={field.value}
disabled={
snapshot.isDragging ||
isSubmitting ||

View File

@ -106,5 +106,5 @@
"vite-plugin-babel-macros": "^1.0.6",
"vite-tsconfig-paths": "^5.1.4"
},
"version": "2.0.12"
"version": "2.0.13"
}

6
package-lock.json generated
View File

@ -1,12 +1,12 @@
{
"name": "@documenso/root",
"version": "2.0.12",
"version": "2.0.13",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@documenso/root",
"version": "2.0.12",
"version": "2.0.13",
"workspaces": [
"apps/*",
"packages/*"
@ -101,7 +101,7 @@
},
"apps/remix": {
"name": "@documenso/remix",
"version": "2.0.12",
"version": "2.0.13",
"dependencies": {
"@cantoo/pdf-lib": "^2.5.2",
"@documenso/api": "*",

View File

@ -1,6 +1,6 @@
{
"private": true,
"version": "2.0.12",
"version": "2.0.13",
"scripts": {
"build": "turbo run build",
"dev": "turbo run dev --filter=@documenso/remix",

View File

@ -1,6 +1,16 @@
import { z } from 'zod';
export const SUPPORTED_LANGUAGE_CODES = ['de', 'en', 'fr', 'es', 'it', 'pl'] as const;
export const SUPPORTED_LANGUAGE_CODES = [
'de',
'en',
'fr',
'es',
'it',
'pl',
'ja',
'ko',
'zh',
] as const;
export const ZSupportedLanguageCodeSchema = z.enum(SUPPORTED_LANGUAGE_CODES).catch('en');
@ -54,6 +64,18 @@ export const SUPPORTED_LANGUAGES: Record<string, SupportedLanguage> = {
short: 'pl',
full: 'Polish',
},
ja: {
short: 'ja',
full: 'Japanese',
},
ko: {
short: 'ko',
full: 'Korean',
},
zh: {
short: 'zh',
full: 'Chinese',
},
} satisfies Record<SupportedLanguageCodes, SupportedLanguage>;
export const isValidLanguageCode = (code: unknown): code is SupportedLanguageCodes =>

View File

@ -25,7 +25,6 @@ import { signPdf } from '@documenso/signing';
import { AppError, AppErrorCode } from '../../../errors/app-error';
import { sendCompletedEmail } from '../../../server-only/document/send-completed-email';
import PostHogServerClient from '../../../server-only/feature-flags/get-post-hog-server-client';
import { getAuditLogsPdf } from '../../../server-only/htmltopdf/get-audit-logs-pdf';
import { getCertificatePdf } from '../../../server-only/htmltopdf/get-certificate-pdf';
import { addRejectionStampToPdf } from '../../../server-only/pdf/add-rejection-stamp-to-pdf';
@ -62,171 +61,120 @@ export const run = async ({
}) => {
const { documentId, sendEmail = true, isResealing = false, requestMetadata } = payload;
const envelope = await prisma.envelope.findFirstOrThrow({
where: {
type: EnvelopeType.DOCUMENT,
secondaryId: mapDocumentIdToSecondaryId(documentId),
},
include: {
documentMeta: true,
recipients: true,
envelopeItems: {
include: {
documentData: true,
field: {
include: {
signature: true,
const { envelopeId, envelopeStatus, isRejected } = await io.runTask('seal-document', async () => {
const envelope = await prisma.envelope.findFirstOrThrow({
where: {
type: EnvelopeType.DOCUMENT,
secondaryId: mapDocumentIdToSecondaryId(documentId),
},
include: {
documentMeta: true,
recipients: true,
envelopeItems: {
include: {
documentData: true,
field: {
include: {
signature: true,
},
},
},
},
},
},
});
if (envelope.envelopeItems.length === 0) {
throw new Error('At least one envelope item required');
}
const settings = await getTeamSettings({
userId: envelope.userId,
teamId: envelope.teamId,
});
const isComplete =
envelope.recipients.some((recipient) => recipient.signingStatus === SigningStatus.REJECTED) ||
envelope.recipients.every((recipient) => recipient.signingStatus === SigningStatus.SIGNED);
if (!isComplete) {
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
message: 'Document is not complete',
});
}
// Seems silly but we need to do this in case the job is re-ran
// after it has already run through the update task further below.
// eslint-disable-next-line @typescript-eslint/require-await
const documentStatus = await io.runTask('get-document-status', async () => {
return envelope.status;
});
if (envelope.envelopeItems.length === 0) {
throw new Error('At least one envelope item required');
}
// This is the same case as above.
let envelopeItems = await io.runTask(
'get-document-data-id',
// eslint-disable-next-line @typescript-eslint/require-await
async () => {
// eslint-disable-next-line unused-imports/no-unused-vars
return envelope.envelopeItems.map(({ field, ...rest }) => ({
...rest,
}));
},
);
const settings = await getTeamSettings({
userId: envelope.userId,
teamId: envelope.teamId,
});
if (envelopeItems.length < 1) {
throw new Error(`Document ${envelope.id} has no envelope items`);
}
const isComplete =
envelope.recipients.some((recipient) => recipient.signingStatus === SigningStatus.REJECTED) ||
envelope.recipients.every((recipient) => recipient.signingStatus === SigningStatus.SIGNED);
const recipients = await prisma.recipient.findMany({
where: {
envelopeId: envelope.id,
role: {
not: RecipientRole.CC,
},
},
});
if (!isComplete) {
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
message: 'Document is not complete',
});
}
// Determine if the document has been rejected by checking if any recipient has rejected it
const rejectedRecipient = recipients.find(
(recipient) => recipient.signingStatus === SigningStatus.REJECTED,
);
let envelopeItems = envelope.envelopeItems;
const isRejected = Boolean(rejectedRecipient);
if (envelopeItems.length < 1) {
throw new Error(`Document ${envelope.id} has no envelope items`);
}
// Get the rejection reason from the rejected recipient
const rejectionReason = rejectedRecipient?.rejectionReason ?? '';
const fields = await prisma.field.findMany({
where: {
envelopeId: envelope.id,
},
include: {
signature: true,
},
});
// Skip the field check if the document is rejected
if (!isRejected && fieldsContainUnsignedRequiredField(fields)) {
throw new Error(`Document ${envelope.id} has unsigned required fields`);
}
if (isResealing) {
// If we're resealing we want to use the initial data for the document
// so we aren't placing fields on top of eachother.
envelopeItems = envelopeItems.map((envelopeItem) => ({
...envelopeItem,
documentData: {
...envelopeItem.documentData,
data: envelopeItem.documentData.initialData,
},
}));
}
if (!envelope.qrToken) {
await prisma.envelope.update({
const recipients = await prisma.recipient.findMany({
where: {
id: envelope.id,
},
data: {
qrToken: prefixedId('qr'),
envelopeId: envelope.id,
role: {
not: RecipientRole.CC,
},
},
});
}
const legacyDocumentId = mapSecondaryIdToDocumentId(envelope.secondaryId);
// Determine if the document has been rejected by checking if any recipient has rejected it
const rejectedRecipient = recipients.find(
(recipient) => recipient.signingStatus === SigningStatus.REJECTED,
);
const { certificateData, auditLogData } = await getCertificateAndAuditLogData({
legacyDocumentId,
documentMeta: envelope.documentMeta,
settings,
});
const isRejected = Boolean(rejectedRecipient);
// !: 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 }>> =
// [];
// Get the rejection reason from the rejected recipient
const rejectionReason = rejectedRecipient?.rejectionReason ?? '';
// for (const envelopeItem of envelopeItems) {
// const task = io.runTask(`decorate-${envelopeItem.id}`, async () => {
// const envelopeItemFields = envelope.envelopeItems.find(
// (item) => item.id === envelopeItem.id,
// )?.field;
const fields = await prisma.field.findMany({
where: {
envelopeId: envelope.id,
},
include: {
signature: true,
},
});
// if (!envelopeItemFields) {
// throw new Error(`Envelope item fields not found for envelope item ${envelopeItem.id}`);
// }
// Skip the field check if the document is rejected
if (!isRejected && fieldsContainUnsignedRequiredField(fields)) {
throw new Error(`Document ${envelope.id} has unsigned required fields`);
}
// return decorateAndSignPdf({
// envelope,
// envelopeItem,
// envelopeItemFields,
// isRejected,
// rejectionReason,
// certificateData,
// auditLogData,
// });
// });
if (isResealing) {
// If we're resealing we want to use the initial data for the document
// so we aren't placing fields on top of eachother.
envelopeItems = envelopeItems.map((envelopeItem) => ({
...envelopeItem,
documentData: {
...envelopeItem.documentData,
data: envelopeItem.documentData.initialData,
},
}));
}
// decoratePromises.push(task);
// }
if (!envelope.qrToken) {
await prisma.envelope.update({
where: {
id: envelope.id,
},
data: {
qrToken: prefixedId('qr'),
},
});
}
// const newDocumentData = await Promise.all(decoratePromises);
const legacyDocumentId = mapSecondaryIdToDocumentId(envelope.secondaryId);
// TODO: Remove once parallelization is working
const newDocumentData: Array<{ oldDocumentDataId: string; newDocumentDataId: string }> = [];
const { certificateData, auditLogData } = await getCertificateAndAuditLogData({
legacyDocumentId,
documentMeta: envelope.documentMeta,
settings,
});
for (const envelopeItem of envelopeItems) {
const result = await io.runTask(`decorate-${envelopeItem.id}`, async () => {
const newDocumentData: Array<{ oldDocumentDataId: string; newDocumentDataId: string }> = [];
for (const envelopeItem of envelopeItems) {
const envelopeItemFields = envelope.envelopeItems.find(
(item) => item.id === envelopeItem.id,
)?.field;
@ -235,7 +183,7 @@ export const run = async ({
throw new Error(`Envelope item fields not found for envelope item ${envelopeItem.id}`);
}
return decorateAndSignPdf({
const result = await decorateAndSignPdf({
envelope,
envelopeItem,
envelopeItemFields,
@ -244,25 +192,10 @@ export const run = async ({
certificateData,
auditLogData,
});
});
newDocumentData.push(result);
}
newDocumentData.push(result);
}
const postHog = PostHogServerClient();
if (postHog) {
postHog.capture({
distinctId: nanoid(),
event: 'App: Document Sealed',
properties: {
documentId: envelope.id,
isRejected,
},
});
}
await io.runTask('update-document', async () => {
await prisma.$transaction(async (tx) => {
for (const { oldDocumentDataId, newDocumentDataId } of newDocumentData) {
const newData = await tx.documentData.findFirstOrThrow({
@ -304,18 +237,24 @@ export const run = async ({
}),
});
});
return {
envelopeId: envelope.id,
envelopeStatus: envelope.status,
isRejected,
};
});
await io.runTask('send-completed-email', async () => {
let shouldSendCompletedEmail = sendEmail && !isResealing && !isRejected;
if (isResealing && !isDocumentCompleted(envelope.status)) {
if (isResealing && !isDocumentCompleted(envelopeStatus)) {
shouldSendCompletedEmail = sendEmail;
}
if (shouldSendCompletedEmail) {
await sendCompletedEmail({
id: { type: 'envelopeId', id: envelope.id },
id: { type: 'envelopeId', id: envelopeId },
requestMetadata,
});
}
@ -323,7 +262,7 @@ export const run = async ({
const updatedEnvelope = await prisma.envelope.findFirstOrThrow({
where: {
id: envelope.id,
id: envelopeId,
},
include: {
documentMeta: true,

View File

@ -31,26 +31,16 @@ export const viewedDocument = async ({
type: EnvelopeType.DOCUMENT,
},
},
include: {
envelope: {
include: {
documentMeta: true,
recipients: true,
},
},
},
});
if (!recipient) {
return;
}
const { envelope } = recipient;
await prisma.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_VIEWED,
envelopeId: envelope.id,
envelopeId: recipient.envelopeId,
user: {
name: recipient.name,
email: recipient.email,
@ -86,7 +76,7 @@ export const viewedDocument = async ({
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED,
envelopeId: envelope.id,
envelopeId: recipient.envelopeId,
user: {
name: recipient.name,
email: recipient.email,
@ -103,6 +93,16 @@ export const viewedDocument = async ({
});
});
const envelope = await prisma.envelope.findUniqueOrThrow({
where: {
id: recipient.envelopeId,
},
include: {
documentMeta: true,
recipients: true,
},
});
await triggerWebhook({
event: WebhookTriggerEvents.DOCUMENT_OPENED,
data: ZWebhookDocumentSchema.parse(mapEnvelopeToWebhookDocumentPayload(envelope)),