Compare commits

..

1 Commits

Author SHA1 Message Date
75d7336763 feat: migrate nextjs to rr7 2025-03-17 14:20:41 +11:00
134 changed files with 2797 additions and 498 deletions

View File

@ -52,9 +52,9 @@ Platform customers have access to advanced styling options to customize the embe
<EmbedDirectTemplate <EmbedDirectTemplate
token={token} token={token}
cssVars={{ cssVars={{
colorPrimary: '#0000FF', primary: '#0000FF',
colorBackground: '#F5F5F5', background: '#F5F5F5',
borderRadius: '8px', radius: '8px',
}} }}
/> />
``` ```

View File

@ -95,9 +95,9 @@ const MyEmbeddingComponent = () => {
} }
`; `;
const cssVars = { const cssVars = {
colorPrimary: '#0000FF', primary: '#0000FF',
colorBackground: '#F5F5F5', background: '#F5F5F5',
borderRadius: '8px', radius: '8px',
}; };
return ( return (

View File

@ -99,9 +99,9 @@ const MyEmbeddingComponent = () => {
`} `}
// CSS Variables // CSS Variables
cssVars={{ cssVars={{
colorPrimary: '#0000FF', primary: '#0000FF',
colorBackground: '#F5F5F5', background: '#F5F5F5',
borderRadius: '8px', radius: '8px',
}} }}
// Dark Mode Control // Dark Mode Control
darkModeDisabled={true} darkModeDisabled={true}

View File

@ -95,9 +95,9 @@ const MyEmbeddingComponent = () => {
} }
`; `;
const cssVars = { const cssVars = {
colorPrimary: '#0000FF', primary: '#0000FF',
colorBackground: '#F5F5F5', background: '#F5F5F5',
borderRadius: '8px', radius: '8px',
}; };
return ( return (

View File

@ -97,9 +97,9 @@ Platform customers have access to advanced styling options:
} }
`; `;
const cssVars = { const cssVars = {
colorPrimary: '#0000FF', primary: '#0000FF',
colorBackground: '#F5F5F5', background: '#F5F5F5',
borderRadius: '8px', radius: '8px',
}; };
</script> </script>

View File

@ -97,9 +97,9 @@ Platform customers have access to advanced styling options:
} }
`; `;
const cssVars = { const cssVars = {
colorPrimary: '#0000FF', primary: '#0000FF',
colorBackground: '#F5F5F5', background: '#F5F5F5',
borderRadius: '8px', radius: '8px',
}; };
</script> </script>

View File

@ -1,7 +1,7 @@
import { DocumentStatus } from '@prisma/client';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { kyselyPrisma, sql } from '@documenso/prisma'; import { kyselyPrisma, sql } from '@documenso/prisma';
import { DocumentStatus } from '@documenso/prisma/client';
export const getCompletedDocumentsMonthly = async (type: 'count' | 'cumulative' = 'count') => { export const getCompletedDocumentsMonthly = async (type: 'count' | 'cumulative' = 'count') => {
const qb = kyselyPrisma.$kysely const qb = kyselyPrisma.$kysely

View File

@ -4,7 +4,7 @@ import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react'; import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import { DocumentStatus } from '@prisma/client'; import { DocumentStatus } from '@prisma/client';
import { match } from 'ts-pattern'; import { P, match } from 'ts-pattern';
import { useLimits } from '@documenso/ee/server-only/limits/provider/client'; import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
import { trpc as trpcReact } from '@documenso/trpc/react'; import { trpc as trpcReact } from '@documenso/trpc/react';
@ -146,7 +146,7 @@ export const DocumentDeleteDialog = ({
</ul> </ul>
</AlertDescription> </AlertDescription>
)) ))
.with(DocumentStatus.COMPLETED, () => ( .with(P.union(DocumentStatus.COMPLETED, DocumentStatus.REJECTED), () => (
<AlertDescription> <AlertDescription>
<p> <p>
<Trans>By deleting this document, the following will occur:</Trans> <Trans>By deleting this document, the following will occur:</Trans>

View File

@ -13,6 +13,10 @@ import { useThrottleFn } from '@documenso/lib/client-only/hooks/use-throttle-fn'
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats'; import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer'; import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones'; import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
import {
isFieldUnsignedAndRequired,
isRequiredField,
} from '@documenso/lib/utils/advanced-fields-helpers';
import { validateFieldsInserted } from '@documenso/lib/utils/fields'; import { validateFieldsInserted } from '@documenso/lib/utils/fields';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import type { import type {
@ -92,7 +96,7 @@ export const EmbedDirectTemplateClientPage = ({
const [localFields, setLocalFields] = useState<DirectTemplateLocalField[]>(() => fields); const [localFields, setLocalFields] = useState<DirectTemplateLocalField[]>(() => fields);
const [pendingFields, _completedFields] = [ const [pendingFields, _completedFields] = [
localFields.filter((field) => !field.inserted), localFields.filter((field) => isFieldUnsignedAndRequired(field)),
localFields.filter((field) => field.inserted), localFields.filter((field) => field.inserted),
]; ];
@ -110,7 +114,7 @@ export const EmbedDirectTemplateClientPage = ({
const newField: DirectTemplateLocalField = structuredClone({ const newField: DirectTemplateLocalField = structuredClone({
...field, ...field,
customText: payload.value, customText: payload.value ?? '',
inserted: true, inserted: true,
signedValue: payload, signedValue: payload,
}); });
@ -121,8 +125,10 @@ export const EmbedDirectTemplateClientPage = ({
created: new Date(), created: new Date(),
recipientId: 1, recipientId: 1,
fieldId: 1, fieldId: 1,
signatureImageAsBase64: payload.value.startsWith('data:') ? payload.value : null, signatureImageAsBase64:
typedSignature: payload.value.startsWith('data:') ? null : payload.value, payload.value && payload.value.startsWith('data:') ? payload.value : null,
typedSignature:
payload.value && !payload.value.startsWith('data:') ? payload.value : null,
} satisfies Signature; } satisfies Signature;
} }
@ -180,7 +186,7 @@ export const EmbedDirectTemplateClientPage = ({
}; };
const onNextFieldClick = () => { const onNextFieldClick = () => {
validateFieldsInserted(localFields); validateFieldsInserted(pendingFields);
setShowPendingFieldTooltip(true); setShowPendingFieldTooltip(true);
setIsExpanded(false); setIsExpanded(false);
@ -192,7 +198,7 @@ export const EmbedDirectTemplateClientPage = ({
return; return;
} }
const valid = validateFieldsInserted(localFields); const valid = validateFieldsInserted(pendingFields);
if (!valid) { if (!valid) {
setShowPendingFieldTooltip(true); setShowPendingFieldTooltip(true);
@ -205,12 +211,6 @@ export const EmbedDirectTemplateClientPage = ({
directTemplateExternalId = decodeURIComponent(directTemplateExternalId); directTemplateExternalId = decodeURIComponent(directTemplateExternalId);
} }
localFields.forEach((field) => {
if (!field.signedValue) {
throw new Error('Invalid configuration');
}
});
const { const {
documentId, documentId,
token: documentToken, token: documentToken,
@ -221,13 +221,11 @@ export const EmbedDirectTemplateClientPage = ({
directRecipientName: fullName, directRecipientName: fullName,
directRecipientEmail: email, directRecipientEmail: email,
templateUpdatedAt: updatedAt, templateUpdatedAt: updatedAt,
signedFieldValues: localFields.map((field) => { signedFieldValues: localFields
if (!field.signedValue) { .filter((field) => {
throw new Error('Invalid configuration'); return field.signedValue && (isRequiredField(field) || field.inserted);
} })
.map((field) => field.signedValue!),
return field.signedValue;
}),
}); });
if (window.parent) { if (window.parent) {
@ -347,7 +345,7 @@ export const EmbedDirectTemplateClientPage = ({
{/* Widget */} {/* Widget */}
<div <div
key={isExpanded ? 'expanded' : 'collapsed'} key={isExpanded ? 'expanded' : 'collapsed'}
className="group/document-widget fixed bottom-8 left-0 z-50 h-fit w-full flex-shrink-0 px-6 md:sticky md:top-4 md:z-auto md:w-[350px] md:px-0" className="group/document-widget fixed bottom-8 left-0 z-50 h-fit max-h-[calc(100dvh-2rem)] w-full flex-shrink-0 px-6 md:sticky md:top-4 md:z-auto md:w-[350px] md:px-0"
data-expanded={isExpanded || undefined} data-expanded={isExpanded || undefined}
> >
<div className="border-border bg-widget flex h-fit w-full flex-col rounded-xl border px-4 py-4 md:min-h-[min(calc(100dvh-2rem),48rem)] md:py-6"> <div className="border-border bg-widget flex h-fit w-full flex-col rounded-xl border px-4 py-4 md:min-h-[min(calc(100dvh-2rem),48rem)] md:py-6">
@ -415,40 +413,42 @@ export const EmbedDirectTemplateClientPage = ({
/> />
</div> </div>
<div> {hasSignatureField && (
<Label htmlFor="Signature"> <div>
<Trans>Signature</Trans> <Label htmlFor="Signature">
</Label> <Trans>Signature</Trans>
</Label>
<Card className="mt-2" gradient degrees={-120}> <Card className="mt-2" gradient degrees={-120}>
<CardContent className="p-0"> <CardContent className="p-0">
<SignaturePad <SignaturePad
className="h-44 w-full" className="h-44 w-full"
disabled={isThrottled || isSubmitting} disabled={isThrottled || isSubmitting}
defaultValue={signature ?? undefined} defaultValue={signature ?? undefined}
onChange={(value) => { onChange={(value) => {
setSignature(value); setSignature(value);
}} }}
onValidityChange={(isValid) => { onValidityChange={(isValid) => {
setSignatureValid(isValid); setSignatureValid(isValid);
}} }}
allowTypedSignature={Boolean( allowTypedSignature={Boolean(
metadata && metadata &&
'typedSignatureEnabled' in metadata && 'typedSignatureEnabled' in metadata &&
metadata.typedSignatureEnabled, metadata.typedSignatureEnabled,
)} )}
/> />
</CardContent> </CardContent>
</Card> </Card>
{hasSignatureField && !signatureValid && ( {hasSignatureField && !signatureValid && (
<div className="text-destructive mt-2 text-sm"> <div className="text-destructive mt-2 text-sm">
<Trans> <Trans>
Signature is too small. Please provide a more complete signature. Signature is too small. Please provide a more complete signature.
</Trans> </Trans>
</div> </div>
)} )}
</div> </div>
)}
</div> </div>
</div> </div>

View File

@ -1,4 +1,4 @@
import { useEffect, useId, useLayoutEffect, useState } from 'react'; import { useEffect, useId, useLayoutEffect, useMemo, useState } from 'react';
import { msg } from '@lingui/core/macro'; import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react'; import { useLingui } from '@lingui/react';
@ -15,6 +15,7 @@ import { LucideChevronDown, LucideChevronUp } from 'lucide-react';
import { useThrottleFn } from '@documenso/lib/client-only/hooks/use-throttle-fn'; import { useThrottleFn } from '@documenso/lib/client-only/hooks/use-throttle-fn';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer'; import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
import { validateFieldsInserted } from '@documenso/lib/utils/fields'; import { validateFieldsInserted } from '@documenso/lib/utils/fields';
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields'; import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
@ -101,19 +102,26 @@ export const EmbedSignDocumentClientPage = ({
const [throttledOnCompleteClick, isThrottled] = useThrottleFn(() => void onCompleteClick(), 500); const [throttledOnCompleteClick, isThrottled] = useThrottleFn(() => void onCompleteClick(), 500);
const [pendingFields, _completedFields] = [ const [pendingFields, _completedFields] = [
fields.filter((field) => field.recipientId === recipient.id && !field.inserted), fields.filter(
(field) => field.recipientId === recipient.id && isFieldUnsignedAndRequired(field),
),
fields.filter((field) => field.inserted), fields.filter((field) => field.inserted),
]; ];
const { mutateAsync: completeDocumentWithToken, isPending: isSubmitting } = const { mutateAsync: completeDocumentWithToken, isPending: isSubmitting } =
trpc.recipient.completeDocumentWithToken.useMutation(); trpc.recipient.completeDocumentWithToken.useMutation();
const fieldsRequiringValidation = useMemo(
() => fields.filter(isFieldUnsignedAndRequired),
[fields],
);
const hasSignatureField = fields.some((field) => field.type === FieldType.SIGNATURE); const hasSignatureField = fields.some((field) => field.type === FieldType.SIGNATURE);
const assistantSignersId = useId(); const assistantSignersId = useId();
const onNextFieldClick = () => { const onNextFieldClick = () => {
validateFieldsInserted(fields); validateFieldsInserted(fieldsRequiringValidation);
setShowPendingFieldTooltip(true); setShowPendingFieldTooltip(true);
setIsExpanded(false); setIsExpanded(false);
@ -125,7 +133,7 @@ export const EmbedSignDocumentClientPage = ({
return; return;
} }
const valid = validateFieldsInserted(fields); const valid = validateFieldsInserted(fieldsRequiringValidation);
if (!valid) { if (!valid) {
setShowPendingFieldTooltip(true); setShowPendingFieldTooltip(true);
@ -287,7 +295,7 @@ export const EmbedSignDocumentClientPage = ({
{/* Widget */} {/* Widget */}
<div <div
key={isExpanded ? 'expanded' : 'collapsed'} key={isExpanded ? 'expanded' : 'collapsed'}
className="embed--DocumentWidgetContainer group/document-widget fixed bottom-8 left-0 z-50 h-fit w-full flex-shrink-0 px-6 md:sticky md:top-4 md:z-auto md:w-[350px] md:px-0" className="embed--DocumentWidgetContainer group/document-widget fixed bottom-8 left-0 z-50 h-fit max-h-[calc(100dvh-2rem)] w-full flex-shrink-0 px-6 md:sticky md:top-4 md:z-auto md:w-[350px] md:px-0"
data-expanded={isExpanded || undefined} data-expanded={isExpanded || undefined}
> >
<div className="embed--DocumentWidget border-border bg-widget flex w-full flex-col rounded-xl border px-4 py-4 md:py-6"> <div className="embed--DocumentWidget border-border bg-widget flex w-full flex-col rounded-xl border px-4 py-4 md:py-6">
@ -418,40 +426,42 @@ export const EmbedSignDocumentClientPage = ({
/> />
</div> </div>
<div> {hasSignatureField && (
<Label htmlFor="Signature"> <div>
<Trans>Signature</Trans> <Label htmlFor="Signature">
</Label> <Trans>Signature</Trans>
</Label>
<Card className="mt-2" gradient degrees={-120}> <Card className="mt-2" gradient degrees={-120}>
<CardContent className="p-0"> <CardContent className="p-0">
<SignaturePad <SignaturePad
className="h-44 w-full" className="h-44 w-full"
disabled={isThrottled || isSubmitting} disabled={isThrottled || isSubmitting}
defaultValue={signature ?? undefined} defaultValue={signature ?? undefined}
onChange={(value) => { onChange={(value) => {
setSignature(value); setSignature(value);
}} }}
onValidityChange={(isValid) => { onValidityChange={(isValid) => {
setSignatureValid(isValid); setSignatureValid(isValid);
}} }}
allowTypedSignature={Boolean( allowTypedSignature={Boolean(
metadata && metadata &&
'typedSignatureEnabled' in metadata && 'typedSignatureEnabled' in metadata &&
metadata.typedSignatureEnabled, metadata.typedSignatureEnabled,
)} )}
/> />
</CardContent> </CardContent>
</Card> </Card>
{hasSignatureField && !signatureValid && ( {hasSignatureField && !signatureValid && (
<div className="text-destructive mt-2 text-sm"> <div className="text-destructive mt-2 text-sm">
<Trans> <Trans>
Signature is too small. Please provide a more complete signature. Signature is too small. Please provide a more complete signature.
</Trans> </Trans>
</div> </div>
)} )}
</div> </div>
)}
</> </>
)} )}
</div> </div>

View File

@ -6,10 +6,10 @@ import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import { flushSync } from 'react-dom'; import { flushSync } from 'react-dom';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { useRevalidator } from 'react-router';
import { z } from 'zod'; import { z } from 'zod';
import { authClient } from '@documenso/auth/client'; import { authClient } from '@documenso/auth/client';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { import {
Dialog, Dialog,
@ -42,7 +42,7 @@ export type TDisable2FAForm = z.infer<typeof ZDisable2FAForm>;
export const DisableAuthenticatorAppDialog = () => { export const DisableAuthenticatorAppDialog = () => {
const { _ } = useLingui(); const { _ } = useLingui();
const { toast } = useToast(); const { toast } = useToast();
const { revalidate } = useRevalidator(); const { refreshSession } = useSession();
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [twoFactorDisableMethod, setTwoFactorDisableMethod] = useState<'totp' | 'backup'>('totp'); const [twoFactorDisableMethod, setTwoFactorDisableMethod] = useState<'totp' | 'backup'>('totp');
@ -92,7 +92,7 @@ export const DisableAuthenticatorAppDialog = () => {
onCloseTwoFactorDisableDialog(); onCloseTwoFactorDisableDialog();
}); });
await revalidate(); await refreshSession();
} catch (_err) { } catch (_err) {
toast({ toast({
title: _(msg`Unable to disable two-factor authentication`), title: _(msg`Unable to disable two-factor authentication`),

View File

@ -5,12 +5,12 @@ import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react'; import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { useRevalidator } from 'react-router';
import { renderSVG } from 'uqr'; import { renderSVG } from 'uqr';
import { z } from 'zod'; import { z } from 'zod';
import { authClient } from '@documenso/auth/client'; import { authClient } from '@documenso/auth/client';
import { downloadFile } from '@documenso/lib/client-only/download-file'; import { downloadFile } from '@documenso/lib/client-only/download-file';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { import {
Dialog, Dialog,
@ -48,7 +48,7 @@ export type EnableAuthenticatorAppDialogProps = {
export const EnableAuthenticatorAppDialog = ({ onSuccess }: EnableAuthenticatorAppDialogProps) => { export const EnableAuthenticatorAppDialog = ({ onSuccess }: EnableAuthenticatorAppDialogProps) => {
const { _ } = useLingui(); const { _ } = useLingui();
const { toast } = useToast(); const { toast } = useToast();
const { revalidate } = useRevalidator(); const { refreshSession } = useSession();
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [recoveryCodes, setRecoveryCodes] = useState<string[] | null>(null); const [recoveryCodes, setRecoveryCodes] = useState<string[] | null>(null);
@ -74,6 +74,7 @@ export const EnableAuthenticatorAppDialog = ({ onSuccess }: EnableAuthenticatorA
try { try {
const data = await authClient.twoFactor.setup(); const data = await authClient.twoFactor.setup();
await refreshSession();
setSetup2FAData(data); setSetup2FAData(data);
} catch (err) { } catch (err) {
@ -92,6 +93,7 @@ export const EnableAuthenticatorAppDialog = ({ onSuccess }: EnableAuthenticatorA
const onEnable2FAFormSubmit = async ({ token }: TEnable2FAForm) => { const onEnable2FAFormSubmit = async ({ token }: TEnable2FAForm) => {
try { try {
const data = await authClient.twoFactor.enable({ code: token }); const data = await authClient.twoFactor.enable({ code: token });
await refreshSession();
setRecoveryCodes(data.recoveryCodes); setRecoveryCodes(data.recoveryCodes);
onSuccess?.(); onSuccess?.();
@ -139,7 +141,6 @@ export const EnableAuthenticatorAppDialog = ({ onSuccess }: EnableAuthenticatorA
if (!isOpen && recoveryCodes && recoveryCodes.length > 0) { if (!isOpen && recoveryCodes && recoveryCodes.length > 0) {
setRecoveryCodes(null); setRecoveryCodes(null);
void revalidate();
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps

View File

@ -76,7 +76,7 @@ export const AppNavDesktop = ({
<Button <Button
variant="outline" variant="outline"
className="text-muted-foreground flex w-96 items-center justify-between rounded-lg" className="text-muted-foreground flex w-full max-w-96 items-center justify-between rounded-lg"
onClick={() => setIsCommandMenuOpen(true)} onClick={() => setIsCommandMenuOpen(true)}
> >
<div className="flex items-center"> <div className="flex items-center">

View File

@ -1,4 +1,4 @@
import { useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import type { Field, Recipient, Signature } from '@prisma/client'; import type { Field, Recipient, Signature } from '@prisma/client';
@ -91,7 +91,7 @@ export const DirectTemplateSigningForm = ({
const tempField: DirectTemplateLocalField = { const tempField: DirectTemplateLocalField = {
...field, ...field,
customText: value.value, customText: value.value ?? '',
inserted: true, inserted: true,
signedValue: value, signedValue: value,
}; };
@ -102,8 +102,8 @@ export const DirectTemplateSigningForm = ({
created: new Date(), created: new Date(),
recipientId: 1, recipientId: 1,
fieldId: 1, fieldId: 1,
signatureImageAsBase64: value.value.startsWith('data:') ? value.value : null, signatureImageAsBase64: value.value?.startsWith('data:') ? value.value : null,
typedSignature: value.value.startsWith('data:') ? null : value.value, typedSignature: value.value && !value.value.startsWith('data:') ? value.value : null,
} satisfies Signature; } satisfies Signature;
} }
@ -170,6 +170,55 @@ export const DirectTemplateSigningForm = ({
// Do not reset to false since we do a redirect. // Do not reset to false since we do a redirect.
}; };
useEffect(() => {
const updatedFields = [...localFields];
localFields.forEach((field) => {
const index = updatedFields.findIndex((f) => f.id === field.id);
let value = '';
match(field.type)
.with(FieldType.TEXT, () => {
const meta = field.fieldMeta ? ZTextFieldMeta.safeParse(field.fieldMeta) : null;
if (meta?.success) {
value = meta.data.text ?? '';
}
})
.with(FieldType.NUMBER, () => {
const meta = field.fieldMeta ? ZNumberFieldMeta.safeParse(field.fieldMeta) : null;
if (meta?.success) {
value = meta.data.value ?? '';
}
})
.with(FieldType.DROPDOWN, () => {
const meta = field.fieldMeta ? ZDropdownFieldMeta.safeParse(field.fieldMeta) : null;
if (meta?.success) {
value = meta.data.defaultValue ?? '';
}
});
if (value) {
const signedValue = {
token: directRecipient.token,
fieldId: field.id,
value,
};
updatedFields[index] = {
...field,
customText: value,
inserted: true,
signedValue,
};
}
});
setLocalFields(updatedFields);
}, []);
return ( return (
<DocumentSigningRecipientProvider recipient={directRecipient}> <DocumentSigningRecipientProvider recipient={directRecipient}>
<DocumentFlowFormContainerHeader title={flowStep.title} description={flowStep.description} /> <DocumentFlowFormContainerHeader title={flowStep.title} description={flowStep.description} />

View File

@ -97,6 +97,10 @@ export const DocumentSigningCheckboxField = ({
const onSign = async (authOptions?: TRecipientActionAuth) => { const onSign = async (authOptions?: TRecipientActionAuth) => {
try { try {
if (!isLengthConditionMet) {
return;
}
const payload: TSignFieldWithTokenMutationSchema = { const payload: TSignFieldWithTokenMutationSchema = {
token: recipient.token, token: recipient.token,
fieldId: field.id, fieldId: field.id,
@ -194,18 +198,30 @@ export const DocumentSigningCheckboxField = ({
setCheckedValues(updatedValues); setCheckedValues(updatedValues);
await removeSignedFieldWithToken({ const removePayload: TRemovedSignedFieldWithTokenMutationSchema = {
token: recipient.token, token: recipient.token,
fieldId: field.id, fieldId: field.id,
}); };
if (updatedValues.length > 0) { if (onUnsignField) {
await signFieldWithToken({ await onUnsignField(removePayload);
} else {
await removeSignedFieldWithToken(removePayload);
}
if (updatedValues.length > 0 && shouldAutoSignField) {
const signPayload: TSignFieldWithTokenMutationSchema = {
token: recipient.token, token: recipient.token,
fieldId: field.id, fieldId: field.id,
value: toCheckboxValue(updatedValues), value: toCheckboxValue(updatedValues),
isBase64: true, isBase64: true,
}); };
if (onSignField) {
await onSignField(signPayload);
} else {
await signFieldWithToken(signPayload);
}
} }
} catch (err) { } catch (err) {
console.error(err); console.error(err);

View File

@ -3,6 +3,7 @@ import { useMemo, useState } from 'react';
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import type { Field } from '@prisma/client'; import type { Field } from '@prisma/client';
import { RecipientRole } from '@prisma/client'; import { RecipientRole } from '@prisma/client';
import { match } from 'ts-pattern';
import { fieldsContainUnsignedRequiredField } from '@documenso/lib/utils/advanced-fields-helpers'; import { fieldsContainUnsignedRequiredField } from '@documenso/lib/utils/advanced-fields-helpers';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
@ -58,62 +59,88 @@ export const DocumentSigningCompleteDialog = ({
loading={isSubmitting} loading={isSubmitting}
disabled={disabled} disabled={disabled}
> >
{isComplete ? <Trans>Complete</Trans> : <Trans>Next field</Trans>} {match({ isComplete, role })
.with({ isComplete: false }, () => <Trans>Next field</Trans>)
.with({ isComplete: true, role: RecipientRole.APPROVER }, () => <Trans>Approve</Trans>)
.with({ isComplete: true, role: RecipientRole.VIEWER }, () => (
<Trans>Mark as viewed</Trans>
))
.with({ isComplete: true }, () => <Trans>Complete</Trans>)
.exhaustive()}
</Button> </Button>
</DialogTrigger> </DialogTrigger>
<DialogContent> <DialogContent>
<DialogTitle> <DialogTitle>
<div className="text-foreground text-xl font-semibold"> <div className="text-foreground text-xl font-semibold">
{role === RecipientRole.VIEWER && <Trans>Complete Viewing</Trans>} {match(role)
{role === RecipientRole.SIGNER && <Trans>Complete Signing</Trans>} .with(RecipientRole.VIEWER, () => <Trans>Complete Viewing</Trans>)
{role === RecipientRole.APPROVER && <Trans>Complete Approval</Trans>} .with(RecipientRole.SIGNER, () => <Trans>Complete Signing</Trans>)
.with(RecipientRole.APPROVER, () => <Trans>Complete Approval</Trans>)
.with(RecipientRole.CC, () => <Trans>Complete Viewing</Trans>)
.with(RecipientRole.ASSISTANT, () => <Trans>Complete Assisting</Trans>)
.exhaustive()}
</div> </div>
</DialogTitle> </DialogTitle>
<div className="text-muted-foreground max-w-[50ch]"> <div className="text-muted-foreground max-w-[50ch]">
{role === RecipientRole.VIEWER && ( {match(role)
<span> .with(RecipientRole.VIEWER, () => (
<Trans> <span>
<span className="inline-flex flex-wrap"> <Trans>
You are about to complete viewing " <span className="inline-flex flex-wrap">
<span className="inline-block max-w-[11rem] truncate align-baseline"> You are about to complete viewing "
{documentTitle} <span className="inline-block max-w-[11rem] truncate align-baseline">
{documentTitle}
</span>
".
</span> </span>
". <br /> Are you sure?
</span> </Trans>
<br /> Are you sure? </span>
</Trans> ))
</span> .with(RecipientRole.SIGNER, () => (
)} <span>
{role === RecipientRole.SIGNER && ( <Trans>
<span> <span className="inline-flex flex-wrap">
<Trans> You are about to complete signing "
<span className="inline-flex flex-wrap"> <span className="inline-block max-w-[11rem] truncate align-baseline">
You are about to complete signing " {documentTitle}
<span className="inline-block max-w-[11rem] truncate align-baseline"> </span>
{documentTitle} ".
</span> </span>
". <br /> Are you sure?
</span> </Trans>
<br /> Are you sure? </span>
</Trans> ))
</span> .with(RecipientRole.APPROVER, () => (
)} <span>
{role === RecipientRole.APPROVER && ( <Trans>
<span> <span className="inline-flex flex-wrap">
<Trans> You are about to complete approving{' '}
<span className="inline-flex flex-wrap"> <span className="inline-block max-w-[11rem] truncate align-baseline">
You are about to complete approving{' '} "{documentTitle}"
<span className="inline-block max-w-[11rem] truncate align-baseline"> </span>
"{documentTitle}" .
</span> </span>
. <br /> Are you sure?
</span> </Trans>
<br /> Are you sure? </span>
</Trans> ))
</span> .otherwise(() => (
)} <span>
<Trans>
<span className="inline-flex flex-wrap">
You are about to complete viewing "
<span className="inline-block max-w-[11rem] truncate align-baseline">
{documentTitle}
</span>
".
</span>
<br /> Are you sure?
</Trans>
</span>
))}
</div> </div>
<DocumentSigningDisclosure className="mt-4" /> <DocumentSigningDisclosure className="mt-4" />
@ -138,9 +165,13 @@ export const DocumentSigningCompleteDialog = ({
loading={isSubmitting} loading={isSubmitting}
onClick={onSignatureComplete} onClick={onSignatureComplete}
> >
{role === RecipientRole.VIEWER && <Trans>Mark as Viewed</Trans>} {match(role)
{role === RecipientRole.SIGNER && <Trans>Sign</Trans>} .with(RecipientRole.VIEWER, () => <Trans>Mark as Viewed</Trans>)
{role === RecipientRole.APPROVER && <Trans>Approve</Trans>} .with(RecipientRole.SIGNER, () => <Trans>Sign</Trans>)
.with(RecipientRole.APPROVER, () => <Trans>Approve</Trans>)
.with(RecipientRole.CC, () => <Trans>Mark as Viewed</Trans>)
.with(RecipientRole.ASSISTANT, () => <Trans>Complete</Trans>)
.exhaustive()}
</Button> </Button>
</div> </div>
</DialogFooter> </DialogFooter>

View File

@ -181,6 +181,23 @@ export const DocumentSigningFieldContainer = ({
</button> </button>
)} )}
{(field.type === FieldType.RADIO || field.type === FieldType.CHECKBOX) &&
field.fieldMeta?.label && (
<div
className={cn(
'absolute -top-16 left-0 right-0 rounded-md p-2 text-center text-xs text-gray-700',
{
'bg-foreground/5 border-border border': !field.inserted,
},
{
'bg-documenso-200 border-primary border': field.inserted,
},
)}
>
{field.fieldMeta.label}
</div>
)}
{children} {children}
</FieldRootContainer> </FieldRootContainer>
</div> </div>

View File

@ -313,7 +313,11 @@ export const DocumentSigningForm = ({
<> <>
<form onSubmit={handleSubmit(onFormSubmit)}> <form onSubmit={handleSubmit(onFormSubmit)}>
<p className="text-muted-foreground mt-2 text-sm"> <p className="text-muted-foreground mt-2 text-sm">
<Trans>Please review the document before signing.</Trans> {recipient.role === RecipientRole.APPROVER && !hasSignatureField ? (
<Trans>Please review the document before approving.</Trans>
) : (
<Trans>Please review the document before signing.</Trans>
)}
</p> </p>
<hr className="border-border mb-8 mt-4" /> <hr className="border-border mb-8 mt-4" />
@ -337,38 +341,40 @@ export const DocumentSigningForm = ({
/> />
</div> </div>
<div> {hasSignatureField && (
<Label htmlFor="Signature"> <div>
<Trans>Signature</Trans> <Label htmlFor="Signature">
</Label> <Trans>Signature</Trans>
</Label>
<Card className="mt-2" gradient degrees={-120}> <Card className="mt-2" gradient degrees={-120}>
<CardContent className="p-0"> <CardContent className="p-0">
<SignaturePad <SignaturePad
className="h-44 w-full" className="h-44 w-full"
disabled={isSubmitting} disabled={isSubmitting}
defaultValue={signature ?? undefined} defaultValue={signature ?? undefined}
onValidityChange={(isValid) => { onValidityChange={(isValid) => {
setSignatureValid(isValid); setSignatureValid(isValid);
}} }}
onChange={(value) => { onChange={(value) => {
if (signatureValid) { if (signatureValid) {
setSignature(value); setSignature(value);
} }
}} }}
allowTypedSignature={document.documentMeta?.typedSignatureEnabled} allowTypedSignature={document.documentMeta?.typedSignatureEnabled}
/> />
</CardContent> </CardContent>
</Card> </Card>
{hasSignatureField && !signatureValid && ( {!signatureValid && (
<div className="text-destructive mt-2 text-sm"> <div className="text-destructive mt-2 text-sm">
<Trans> <Trans>
Signature is too small. Please provide a more complete signature. Signature is too small. Please provide a more complete signature.
</Trans> </Trans>
</div> </div>
)} )}
</div> </div>
)}
</div> </div>
<div className="flex flex-col gap-4 md:flex-row"> <div className="flex flex-col gap-4 md:flex-row">

View File

@ -1,9 +1,10 @@
import { msg } from '@lingui/core/macro'; import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react'; import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import { DocumentStatus } from '@prisma/client'; import type { DocumentStatus } from '@prisma/client';
import { DownloadIcon } from 'lucide-react'; import { DownloadIcon } from 'lucide-react';
import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
@ -76,7 +77,7 @@ export const DocumentCertificateDownloadButton = ({
className={cn('w-full sm:w-auto', className)} className={cn('w-full sm:w-auto', className)}
loading={isPending} loading={isPending}
variant="outline" variant="outline"
disabled={documentStatus !== DocumentStatus.COMPLETED} disabled={!isDocumentCompleted(documentStatus)}
onClick={() => void onDownloadCertificatesClick()} onClick={() => void onDownloadCertificatesClick()}
> >
{!isPending && <DownloadIcon className="mr-1.5 h-4 w-4" />} {!isPending && <DownloadIcon className="mr-1.5 h-4 w-4" />}

View File

@ -9,6 +9,7 @@ import { match } from 'ts-pattern';
import { downloadPDF } from '@documenso/lib/client-only/download-pdf'; import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
import { useSession } from '@documenso/lib/client-only/providers/session'; import { useSession } from '@documenso/lib/client-only/providers/session';
import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { formatDocumentsPath } from '@documenso/lib/utils/teams'; import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { trpc as trpcClient } from '@documenso/trpc/client'; import { trpc as trpcClient } from '@documenso/trpc/client';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
@ -32,7 +33,7 @@ export const DocumentPageViewButton = ({ document }: DocumentPageViewButtonProps
const isRecipient = !!recipient; const isRecipient = !!recipient;
const isPending = document.status === DocumentStatus.PENDING; const isPending = document.status === DocumentStatus.PENDING;
const isComplete = document.status === DocumentStatus.COMPLETED; const isComplete = isDocumentCompleted(document);
const isSigned = recipient?.signingStatus === SigningStatus.SIGNED; const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
const role = recipient?.role; const role = recipient?.role;

View File

@ -20,6 +20,7 @@ import { useNavigate } from 'react-router';
import { downloadPDF } from '@documenso/lib/client-only/download-pdf'; import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
import { useSession } from '@documenso/lib/client-only/providers/session'; import { useSession } from '@documenso/lib/client-only/providers/session';
import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { formatDocumentsPath } from '@documenso/lib/utils/teams'; import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { trpc as trpcClient } from '@documenso/trpc/client'; import { trpc as trpcClient } from '@documenso/trpc/client';
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button'; import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
@ -63,7 +64,7 @@ export const DocumentPageViewDropdown = ({ document }: DocumentPageViewDropdownP
const isDraft = document.status === DocumentStatus.DRAFT; const isDraft = document.status === DocumentStatus.DRAFT;
const isPending = document.status === DocumentStatus.PENDING; const isPending = document.status === DocumentStatus.PENDING;
const isDeleted = document.deletedAt !== null; const isDeleted = document.deletedAt !== null;
const isComplete = document.status === DocumentStatus.COMPLETED; const isComplete = isDocumentCompleted(document);
const isCurrentTeamDocument = team && document.team?.url === team.url; const isCurrentTeamDocument = team && document.team?.url === team.url;
const canManageDocument = Boolean(isOwner || isCurrentTeamDocument); const canManageDocument = Boolean(isOwner || isCurrentTeamDocument);

View File

@ -17,6 +17,7 @@ import { Link } from 'react-router';
import { match } from 'ts-pattern'; import { match } from 'ts-pattern';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles'; import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { formatSigningLink } from '@documenso/lib/utils/recipients'; import { formatSigningLink } from '@documenso/lib/utils/recipients';
import { CopyTextButton } from '@documenso/ui/components/common/copy-text-button'; import { CopyTextButton } from '@documenso/ui/components/common/copy-text-button';
import { SignatureIcon } from '@documenso/ui/icons/signature'; import { SignatureIcon } from '@documenso/ui/icons/signature';
@ -48,7 +49,7 @@ export const DocumentPageViewRecipients = ({
<Trans>Recipients</Trans> <Trans>Recipients</Trans>
</h1> </h1>
{document.status !== DocumentStatus.COMPLETED && ( {!isDocumentCompleted(document.status) && (
<Link <Link
to={`${documentRootPath}/${document.id}/edit?step=signers`} to={`${documentRootPath}/${document.id}/edit?step=signers`}
title={_(msg`Modify recipients`)} title={_(msg`Modify recipients`)}

View File

@ -3,7 +3,7 @@ import type { HTMLAttributes } from 'react';
import type { MessageDescriptor } from '@lingui/core'; import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro'; import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react'; import { useLingui } from '@lingui/react';
import { CheckCircle2, Clock, File } from 'lucide-react'; import { CheckCircle2, Clock, File, XCircle } from 'lucide-react';
import type { LucideIcon } from 'lucide-react/dist/lucide-react'; import type { LucideIcon } from 'lucide-react/dist/lucide-react';
import type { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status'; import type { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
@ -36,6 +36,12 @@ export const FRIENDLY_STATUS_MAP: Record<ExtendedDocumentStatus, FriendlyStatus>
icon: File, icon: File,
color: 'text-yellow-500 dark:text-yellow-200', color: 'text-yellow-500 dark:text-yellow-200',
}, },
REJECTED: {
label: msg`Rejected`,
labelExtended: msg`Document rejected`,
icon: XCircle,
color: 'text-red-500 dark:text-red-300',
},
INBOX: { INBOX: {
label: msg`Inbox`, label: msg`Inbox`,
labelExtended: msg`Document inbox`, labelExtended: msg`Document inbox`,

View File

@ -62,7 +62,7 @@ export const GenericErrorLayout = ({
const team = useOptionalCurrentTeam(); const team = useOptionalCurrentTeam();
const { subHeading, heading, message } = const { subHeading, heading, message } =
errorCodeMap[errorCode || 404] ?? defaultErrorCodeMap[500]; errorCodeMap[errorCode || 500] ?? defaultErrorCodeMap[500];
return ( return (
<div className="fixed inset-0 z-0 flex h-screen w-screen items-center justify-center"> <div className="fixed inset-0 z-0 flex h-screen w-screen items-center justify-center">

View File

@ -2,6 +2,9 @@ import { useCallback, useEffect } from 'react';
import { useRevalidator } from 'react-router'; import { useRevalidator } from 'react-router';
/**
* Not really used anymore, this causes random 500s when the user refreshes while this occurs.
*/
export const RefreshOnFocus = () => { export const RefreshOnFocus = () => {
const { revalidate, state } = useRevalidator(); const { revalidate, state } = useRevalidator();

View File

@ -9,6 +9,7 @@ import { match } from 'ts-pattern';
import { downloadPDF } from '@documenso/lib/client-only/download-pdf'; import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
import { useSession } from '@documenso/lib/client-only/providers/session'; import { useSession } from '@documenso/lib/client-only/providers/session';
import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { formatDocumentsPath } from '@documenso/lib/utils/teams'; import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { trpc as trpcClient } from '@documenso/trpc/client'; import { trpc as trpcClient } from '@documenso/trpc/client';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
@ -37,7 +38,7 @@ export const DocumentsTableActionButton = ({ row }: DocumentsTableActionButtonPr
const isRecipient = !!recipient; const isRecipient = !!recipient;
const isDraft = row.status === DocumentStatus.DRAFT; const isDraft = row.status === DocumentStatus.DRAFT;
const isPending = row.status === DocumentStatus.PENDING; const isPending = row.status === DocumentStatus.PENDING;
const isComplete = row.status === DocumentStatus.COMPLETED; const isComplete = isDocumentCompleted(row.status);
const isSigned = recipient?.signingStatus === SigningStatus.SIGNED; const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
const role = recipient?.role; const role = recipient?.role;
const isCurrentTeamDocument = team && row.team?.url === team.url; const isCurrentTeamDocument = team && row.team?.url === team.url;

View File

@ -22,6 +22,7 @@ import { Link } from 'react-router';
import { downloadPDF } from '@documenso/lib/client-only/download-pdf'; import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
import { useSession } from '@documenso/lib/client-only/providers/session'; import { useSession } from '@documenso/lib/client-only/providers/session';
import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { formatDocumentsPath } from '@documenso/lib/utils/teams'; import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { trpc as trpcClient } from '@documenso/trpc/client'; import { trpc as trpcClient } from '@documenso/trpc/client';
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button'; import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
@ -66,7 +67,7 @@ export const DocumentsTableActionDropdown = ({ row }: DocumentsTableActionDropdo
// const isRecipient = !!recipient; // const isRecipient = !!recipient;
const isDraft = row.status === DocumentStatus.DRAFT; const isDraft = row.status === DocumentStatus.DRAFT;
const isPending = row.status === DocumentStatus.PENDING; const isPending = row.status === DocumentStatus.PENDING;
const isComplete = row.status === DocumentStatus.COMPLETED; const isComplete = isDocumentCompleted(row.status);
// const isSigned = recipient?.signingStatus === SigningStatus.SIGNED; // const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
const isCurrentTeamDocument = team && row.team?.url === team.url; const isCurrentTeamDocument = team && row.team?.url === team.url;
const canManageDocument = Boolean(isOwner || isCurrentTeamDocument); const canManageDocument = Boolean(isOwner || isCurrentTeamDocument);

View File

@ -9,8 +9,8 @@ import { match } from 'ts-pattern';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params'; import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import { useSession } from '@documenso/lib/client-only/providers/session'; import { useSession } from '@documenso/lib/client-only/providers/session';
import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { formatDocumentsPath } from '@documenso/lib/utils/teams'; import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
import type { TFindDocumentsResponse } from '@documenso/trpc/server/document-router/schema'; import type { TFindDocumentsResponse } from '@documenso/trpc/server/document-router/schema';
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table'; import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
import { DataTable } from '@documenso/ui/primitives/data-table'; import { DataTable } from '@documenso/ui/primitives/data-table';
@ -77,7 +77,7 @@ export const DocumentsTable = ({ data, isLoading, isLoadingError }: DocumentsTab
{ {
header: _(msg`Actions`), header: _(msg`Actions`),
cell: ({ row }) => cell: ({ row }) =>
(!row.original.deletedAt || row.original.status === ExtendedDocumentStatus.COMPLETED) && ( (!row.original.deletedAt || isDocumentCompleted(row.original.status)) && (
<div className="flex items-center gap-x-4"> <div className="flex items-center gap-x-4">
<DocumentsTableActionButton row={row.original} /> <DocumentsTableActionButton row={row.original} />
<DocumentsTableActionDropdown row={row.original} /> <DocumentsTableActionDropdown row={row.original} />

View File

@ -1,7 +1,6 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import Plausible from 'plausible-tracker'; import Plausible from 'plausible-tracker';
import posthog from 'posthog-js';
import { import {
Links, Links,
Meta, Meta,
@ -28,7 +27,6 @@ import { TooltipProvider } from '@documenso/ui/primitives/tooltip';
import type { Route } from './+types/root'; import type { Route } from './+types/root';
import stylesheet from './app.css?url'; import stylesheet from './app.css?url';
import { GenericErrorLayout } from './components/general/generic-error-layout'; import { GenericErrorLayout } from './components/general/generic-error-layout';
import { RefreshOnFocus } from './components/general/refresh-on-focus';
import { langCookie } from './storage/lang-cookie.server'; import { langCookie } from './storage/lang-cookie.server';
import { themeSessionResolver } from './storage/theme-session.server'; import { themeSessionResolver } from './storage/theme-session.server';
import { appMetaTags } from './utils/meta'; import { appMetaTags } from './utils/meta';
@ -160,8 +158,6 @@ export function LayoutContent({ children }: { children: React.ReactNode }) {
<ScrollRestoration /> <ScrollRestoration />
<Scripts /> <Scripts />
<RefreshOnFocus />
<script <script
dangerouslySetInnerHTML={{ dangerouslySetInnerHTML={{
__html: `window.__ENV__ = ${JSON.stringify(publicEnv)}`, __html: `window.__ENV__ = ${JSON.stringify(publicEnv)}`,
@ -181,7 +177,6 @@ export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
if (errorCode !== 404) { if (errorCode !== 404) {
console.error('[RootErrorBoundary]', error); console.error('[RootErrorBoundary]', error);
posthog.captureException(error);
} }
return <GenericErrorLayout errorCode={errorCode} />; return <GenericErrorLayout errorCode={errorCode} />;

View File

@ -103,7 +103,9 @@ export default function AdminDocumentDetailsPage({ loaderData }: Route.Component
variant="outline" variant="outline"
loading={isResealDocumentLoading} loading={isResealDocumentLoading}
disabled={document.recipients.some( disabled={document.recipients.some(
(recipient) => recipient.signingStatus !== SigningStatus.SIGNED, (recipient) =>
recipient.signingStatus !== SigningStatus.SIGNED &&
recipient.signingStatus !== SigningStatus.REJECTED,
)} )}
onClick={() => resealDocument({ id: document.id })} onClick={() => resealDocument({ id: document.id })}
> >

View File

@ -220,6 +220,9 @@ export default function DocumentPage() {
.with(DocumentStatus.COMPLETED, () => ( .with(DocumentStatus.COMPLETED, () => (
<Trans>This document has been signed by all recipients</Trans> <Trans>This document has been signed by all recipients</Trans>
)) ))
.with(DocumentStatus.REJECTED, () => (
<Trans>This document has been rejected by a recipient</Trans>
))
.with(DocumentStatus.DRAFT, () => ( .with(DocumentStatus.DRAFT, () => (
<Trans>This document is currently a draft and has not been sent</Trans> <Trans>This document is currently a draft and has not been sent</Trans>
)) ))

View File

@ -1,5 +1,5 @@
import { Plural, Trans } from '@lingui/react/macro'; import { Plural, Trans } from '@lingui/react/macro';
import { DocumentStatus as InternalDocumentStatus, TeamMemberRole } from '@prisma/client'; import { TeamMemberRole } from '@prisma/client';
import { ChevronLeft, Users2 } from 'lucide-react'; import { ChevronLeft, Users2 } from 'lucide-react';
import { Link, redirect } from 'react-router'; import { Link, redirect } from 'react-router';
import { match } from 'ts-pattern'; import { match } from 'ts-pattern';
@ -9,6 +9,7 @@ import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-ent
import { getDocumentWithDetailsById } from '@documenso/lib/server-only/document/get-document-with-details-by-id'; import { getDocumentWithDetailsById } from '@documenso/lib/server-only/document/get-document-with-details-by-id';
import { type TGetTeamByUrlResponse, getTeamByUrl } from '@documenso/lib/server-only/team/get-team'; import { type TGetTeamByUrlResponse, getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
import { DocumentVisibility } from '@documenso/lib/types/document-visibility'; import { DocumentVisibility } from '@documenso/lib/types/document-visibility';
import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { formatDocumentsPath } from '@documenso/lib/utils/teams'; import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { DocumentEditForm } from '~/components/general/document/document-edit-form'; import { DocumentEditForm } from '~/components/general/document/document-edit-form';
@ -71,7 +72,7 @@ export async function loader({ params, request }: Route.LoaderArgs) {
throw redirect(documentRootPath); throw redirect(documentRootPath);
} }
if (document.status === InternalDocumentStatus.COMPLETED) { if (isDocumentCompleted(document.status)) {
throw redirect(`${documentRootPath}/${documentId}`); throw redirect(`${documentRootPath}/${documentId}`);
} }

View File

@ -129,7 +129,7 @@ export default function DocumentsLogsPage({ loaderData }: Route.ComponentProps)
<Trans>Document</Trans> <Trans>Document</Trans>
</Link> </Link>
<div className="flex flex-col justify-between truncate sm:flex-row"> <div className="flex flex-col">
<div> <div>
<h1 <h1
className="mt-4 block max-w-[20rem] truncate text-2xl font-semibold md:max-w-[30rem] md:text-3xl" className="mt-4 block max-w-[20rem] truncate text-2xl font-semibold md:max-w-[30rem] md:text-3xl"
@ -137,7 +137,8 @@ export default function DocumentsLogsPage({ loaderData }: Route.ComponentProps)
> >
{document.title} {document.title}
</h1> </h1>
</div>
<div className="mt-1 flex flex-col justify-between sm:flex-row">
<div className="mt-2.5 flex items-center gap-x-6"> <div className="mt-2.5 flex items-center gap-x-6">
<DocumentStatusComponent <DocumentStatusComponent
inheritColor inheritColor
@ -145,16 +146,15 @@ export default function DocumentsLogsPage({ loaderData }: Route.ComponentProps)
className="text-muted-foreground" className="text-muted-foreground"
/> />
</div> </div>
</div> <div className="mt-4 flex w-full flex-row sm:mt-0 sm:w-auto sm:self-end">
<DocumentCertificateDownloadButton
className="mr-2"
documentId={document.id}
documentStatus={document.status}
/>
<div className="mt-4 flex w-full flex-row sm:mt-0 sm:w-auto sm:self-end"> <DocumentAuditLogDownloadButton documentId={document.id} />
<DocumentCertificateDownloadButton </div>
className="mr-2"
documentId={document.id}
documentStatus={document.status}
/>
<DocumentAuditLogDownloadButton documentId={document.id} />
</div> </div>
</div> </div>
@ -163,7 +163,7 @@ export default function DocumentsLogsPage({ loaderData }: Route.ComponentProps)
{documentInformation.map((info, i) => ( {documentInformation.map((info, i) => (
<div className="text-foreground text-sm" key={i}> <div className="text-foreground text-sm" key={i}>
<h3 className="font-semibold">{_(info.description)}</h3> <h3 className="font-semibold">{_(info.description)}</h3>
<p className="text-muted-foreground">{info.value}</p> <p className="text-muted-foreground truncate">{info.value}</p>
</div> </div>
))} ))}

View File

@ -50,6 +50,7 @@ export default function DocumentsPage() {
[ExtendedDocumentStatus.DRAFT]: 0, [ExtendedDocumentStatus.DRAFT]: 0,
[ExtendedDocumentStatus.PENDING]: 0, [ExtendedDocumentStatus.PENDING]: 0,
[ExtendedDocumentStatus.COMPLETED]: 0, [ExtendedDocumentStatus.COMPLETED]: 0,
[ExtendedDocumentStatus.REJECTED]: 0,
[ExtendedDocumentStatus.INBOX]: 0, [ExtendedDocumentStatus.INBOX]: 0,
[ExtendedDocumentStatus.ALL]: 0, [ExtendedDocumentStatus.ALL]: 0,
}); });

View File

@ -1,6 +1,6 @@
import { msg } from '@lingui/core/macro'; import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react'; import { useLingui } from '@lingui/react';
import { FieldType } from '@prisma/client'; import { FieldType, SigningStatus } from '@prisma/client';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { redirect } from 'react-router'; import { redirect } from 'react-router';
import { match } from 'ts-pattern'; import { match } from 'ts-pattern';
@ -159,6 +159,13 @@ export default function SigningCertificate({ loaderData }: Route.ComponentProps)
log.type === DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED && log.type === DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED &&
log.data.recipientId === recipientId, log.data.recipientId === recipientId,
), ),
[DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_REJECTED]: auditLogs[
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_REJECTED
].filter(
(log) =>
log.type === DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_REJECTED &&
log.data.recipientId === recipientId,
),
}; };
}; };
@ -282,25 +289,42 @@ export default function SigningCertificate({ loaderData }: Route.ComponentProps)
</span> </span>
</p> </p>
<p className="text-muted-foreground text-sm print:text-xs"> {logs.DOCUMENT_RECIPIENT_REJECTED[0] ? (
<span className="font-medium">{_(msg`Signed`)}:</span>{' '} <p className="text-muted-foreground text-sm print:text-xs">
<span className="inline-block"> <span className="font-medium">{_(msg`Rejected`)}:</span>{' '}
{logs.DOCUMENT_RECIPIENT_COMPLETED[0] <span className="inline-block">
? DateTime.fromJSDate(logs.DOCUMENT_RECIPIENT_COMPLETED[0].createdAt) {logs.DOCUMENT_RECIPIENT_REJECTED[0]
.setLocale(APP_I18N_OPTIONS.defaultLocale) ? DateTime.fromJSDate(logs.DOCUMENT_RECIPIENT_REJECTED[0].createdAt)
.toFormat('yyyy-MM-dd hh:mm:ss a (ZZZZ)') .setLocale(APP_I18N_OPTIONS.defaultLocale)
: _(msg`Unknown`)} .toFormat('yyyy-MM-dd hh:mm:ss a (ZZZZ)')
</span> : _(msg`Unknown`)}
</p> </span>
</p>
) : (
<p className="text-muted-foreground text-sm print:text-xs">
<span className="font-medium">{_(msg`Signed`)}:</span>{' '}
<span className="inline-block">
{logs.DOCUMENT_RECIPIENT_COMPLETED[0]
? DateTime.fromJSDate(
logs.DOCUMENT_RECIPIENT_COMPLETED[0].createdAt,
)
.setLocale(APP_I18N_OPTIONS.defaultLocale)
.toFormat('yyyy-MM-dd hh:mm:ss a (ZZZZ)')
: _(msg`Unknown`)}
</span>
</p>
)}
<p className="text-muted-foreground text-sm print:text-xs"> <p className="text-muted-foreground text-sm print:text-xs">
<span className="font-medium">{_(msg`Reason`)}:</span>{' '} <span className="font-medium">{_(msg`Reason`)}:</span>{' '}
<span className="inline-block"> <span className="inline-block">
{_( {recipient.signingStatus === SigningStatus.REJECTED
isOwner(recipient.email) ? recipient.rejectionReason
? FRIENDLY_SIGNING_REASONS['__OWNER__'] : _(
: FRIENDLY_SIGNING_REASONS[recipient.role], isOwner(recipient.email)
)} ? FRIENDLY_SIGNING_REASONS['__OWNER__']
: FRIENDLY_SIGNING_REASONS[recipient.role],
)}
</span> </span>
</p> </p>
</div> </div>

View File

@ -4,7 +4,7 @@ import { msg } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import { PlusIcon } from 'lucide-react'; import { PlusIcon } from 'lucide-react';
import { ChevronLeft } from 'lucide-react'; import { ChevronLeft } from 'lucide-react';
import { Link, Outlet } from 'react-router'; import { Link, Outlet, isRouteErrorResponse } from 'react-router';
import LogoIcon from '@documenso/assets/logo_icon.png'; import LogoIcon from '@documenso/assets/logo_icon.png';
import { useOptionalSession } from '@documenso/lib/client-only/providers/session'; import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
@ -16,6 +16,8 @@ import { BrandingLogo } from '~/components/general/branding-logo';
import { GenericErrorLayout } from '~/components/general/generic-error-layout'; import { GenericErrorLayout } from '~/components/general/generic-error-layout';
import { appMetaTags } from '~/utils/meta'; import { appMetaTags } from '~/utils/meta';
import type { Route } from './+types/_layout';
export function meta() { export function meta() {
return appMetaTags('Profile'); return appMetaTags('Profile');
} }
@ -96,7 +98,9 @@ export default function PublicProfileLayout() {
); );
} }
export function ErrorBoundary() { export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
const errorCode = isRouteErrorResponse(error) ? error.status : 500;
const errorCodeMap = { const errorCodeMap = {
404: { 404: {
subHeading: msg`404 Profile not found`, subHeading: msg`404 Profile not found`,
@ -107,6 +111,7 @@ export function ErrorBoundary() {
return ( return (
<GenericErrorLayout <GenericErrorLayout
errorCode={errorCode}
errorCodeMap={errorCodeMap} errorCodeMap={errorCodeMap}
secondaryButton={null} secondaryButton={null}
primaryButton={ primaryButton={

View File

@ -1,6 +1,6 @@
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import { ChevronLeft } from 'lucide-react'; import { ChevronLeft } from 'lucide-react';
import { Link, Outlet } from 'react-router'; import { Link, Outlet, isRouteErrorResponse } from 'react-router';
import { useOptionalSession } from '@documenso/lib/client-only/providers/session'; import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
@ -8,6 +8,8 @@ import { Button } from '@documenso/ui/primitives/button';
import { Header as AuthenticatedHeader } from '~/components/general/app-header'; import { Header as AuthenticatedHeader } from '~/components/general/app-header';
import { GenericErrorLayout } from '~/components/general/generic-error-layout'; import { GenericErrorLayout } from '~/components/general/generic-error-layout';
import type { Route } from './+types/_layout';
/** /**
* A layout to handle scenarios where the user is a recipient of a given resource * A layout to handle scenarios where the user is a recipient of a given resource
* where we do not care whether they are authenticated or not. * where we do not care whether they are authenticated or not.
@ -30,9 +32,12 @@ export default function RecipientLayout() {
); );
} }
export function ErrorBoundary() { export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
const errorCode = isRouteErrorResponse(error) ? error.status : 500;
return ( return (
<GenericErrorLayout <GenericErrorLayout
errorCode={errorCode}
secondaryButton={null} secondaryButton={null}
primaryButton={ primaryButton={
<Button asChild className="w-32"> <Button asChild className="w-32">

View File

@ -160,7 +160,7 @@ export default function SigningPage() {
recipientWithFields, recipientWithFields,
} = data; } = data;
if (document.deletedAt) { if (document.deletedAt || document.status === DocumentStatus.REJECTED) {
return ( return (
<div className="-mx-4 flex max-w-[100vw] flex-col items-center overflow-x-hidden px-4 pt-16 md:-mx-8 md:px-8 lg:pt-16 xl:pt-24"> <div className="-mx-4 flex max-w-[100vw] flex-col items-center overflow-x-hidden px-4 pt-16 md:-mx-8 md:px-8 lg:pt-16 xl:pt-24">
<SigningCard3D <SigningCard3D

View File

@ -17,6 +17,7 @@ import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-f
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token'; import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get-recipient-signatures'; import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get-recipient-signatures';
import { getUserByEmail } from '@documenso/lib/server-only/user/get-user-by-email'; import { getUserByEmail } from '@documenso/lib/server-only/user/get-user-by-email';
import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { env } from '@documenso/lib/utils/env'; import { env } from '@documenso/lib/utils/env';
import DocumentDialog from '@documenso/ui/components/document/document-dialog'; import DocumentDialog from '@documenso/ui/components/document/document-dialog';
import { DocumentDownloadButton } from '@documenso/ui/components/document/document-download-button'; import { DocumentDownloadButton } from '@documenso/ui/components/document/document-download-button';
@ -205,12 +206,12 @@ export default function CompletedSigningPage({ loaderData }: Route.ComponentProp
<div className="mt-8 flex w-full max-w-sm items-center justify-center gap-4"> <div className="mt-8 flex w-full max-w-sm items-center justify-center gap-4">
<DocumentShareButton documentId={document.id} token={recipient.token} /> <DocumentShareButton documentId={document.id} token={recipient.token} />
{document.status === DocumentStatus.COMPLETED ? ( {isDocumentCompleted(document.status) ? (
<DocumentDownloadButton <DocumentDownloadButton
className="flex-1" className="flex-1"
fileName={document.title} fileName={document.title}
documentData={document.documentData} documentData={document.documentData}
disabled={document.status !== DocumentStatus.COMPLETED} disabled={!isDocumentCompleted(document.status)}
/> />
) : ( ) : (
<DocumentDialog <DocumentDialog
@ -268,7 +269,7 @@ export const PollUntilDocumentCompleted = ({ document }: PollUntilDocumentComple
const { revalidate } = useRevalidator(); const { revalidate } = useRevalidator();
useEffect(() => { useEffect(() => {
if (document.status === DocumentStatus.COMPLETED) { if (isDocumentCompleted(document.status)) {
return; return;
} }

View File

@ -19,18 +19,30 @@ const posthogProxy = async (request: Request) => {
const headers = new Headers(request.headers); const headers = new Headers(request.headers);
headers.set('host', hostname); headers.set('host', hostname);
const response = await fetch(newUrl, { const fetchOptions: RequestInit = {
method: request.method, method: request.method,
headers, headers,
body: request.body, redirect: 'follow',
// @ts-expect-error - Not really sure about this };
duplex: 'half',
}); if (!['GET', 'HEAD'].includes(request.method)) {
fetchOptions.body = request.body;
// @ts-expect-error - It should exist
fetchOptions.duplex = 'half';
}
const response = await fetch(newUrl, fetchOptions);
const responseHeaders = new Headers(response.headers);
responseHeaders.delete('content-encoding');
responseHeaders.delete('content-length');
responseHeaders.delete('transfer-encoding');
responseHeaders.delete('cookie');
return new Response(response.body, { return new Response(response.body, {
status: response.status, status: response.status,
statusText: response.statusText, statusText: response.statusText,
headers: response.headers, headers: responseHeaders,
}); });
}; };

View File

@ -145,7 +145,9 @@ export default function EmbedDirectTemplatePage() {
recipient={recipient} recipient={recipient}
fields={fields} fields={fields}
metadata={template.templateMeta} metadata={template.templateMeta}
hidePoweredBy={isPlatformDocument || isEnterpriseDocument || hidePoweredBy} hidePoweredBy={
isCommunityPlan || isPlatformDocument || isEnterpriseDocument || hidePoweredBy
}
allowWhiteLabelling={isCommunityPlan || isPlatformDocument || isEnterpriseDocument} allowWhiteLabelling={isCommunityPlan || isPlatformDocument || isEnterpriseDocument}
/> />
</DocumentSigningRecipientProvider> </DocumentSigningRecipientProvider>

View File

@ -1,4 +1,4 @@
import { DocumentStatus, RecipientRole } from '@prisma/client'; import { RecipientRole } from '@prisma/client';
import { data } from 'react-router'; import { data } from 'react-router';
import { match } from 'ts-pattern'; import { match } from 'ts-pattern';
@ -14,6 +14,7 @@ import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-re
import { getRecipientsForAssistant } from '@documenso/lib/server-only/recipient/get-recipients-for-assistant'; import { getRecipientsForAssistant } from '@documenso/lib/server-only/recipient/get-recipients-for-assistant';
import { getTeamById } from '@documenso/lib/server-only/team/get-team'; import { getTeamById } from '@documenso/lib/server-only/team/get-team';
import { DocumentAccessAuth } from '@documenso/lib/types/document-auth'; import { DocumentAccessAuth } from '@documenso/lib/types/document-auth';
import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth'; import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import { EmbedSignDocumentClientPage } from '~/components/embed/embed-document-signing-page'; import { EmbedSignDocumentClientPage } from '~/components/embed/embed-document-signing-page';
@ -168,8 +169,10 @@ export default function EmbedSignDocumentPage() {
recipient={recipient} recipient={recipient}
fields={fields} fields={fields}
metadata={document.documentMeta} metadata={document.documentMeta}
isCompleted={document.status === DocumentStatus.COMPLETED} isCompleted={isDocumentCompleted(document.status)}
hidePoweredBy={isPlatformDocument || isEnterpriseDocument || hidePoweredBy} hidePoweredBy={
isCommunityPlan || isPlatformDocument || isEnterpriseDocument || hidePoweredBy
}
allowWhitelabelling={isCommunityPlan || isPlatformDocument || isEnterpriseDocument} allowWhitelabelling={isCommunityPlan || isPlatformDocument || isEnterpriseDocument}
allRecipients={allRecipients} allRecipients={allRecipients}
/> />

View File

@ -12,6 +12,7 @@ const themeSessionStorage = createCookieSessionStorage({
secrets: ['insecure-secret-do-not-care'], secrets: ['insecure-secret-do-not-care'],
secure: useSecureCookies, secure: useSecureCookies,
domain: getCookieDomain(), domain: getCookieDomain(),
maxAge: 60 * 60 * 24 * 365,
}, },
}); });

View File

@ -14,7 +14,7 @@
"prepare": "husky && husky install || true", "prepare": "husky && husky install || true",
"commitlint": "commitlint --edit", "commitlint": "commitlint --edit",
"clean": "turbo run clean && rimraf node_modules", "clean": "turbo run clean && rimraf node_modules",
"d": "npm run dx && npm run dev", "d": "npm run dx && npm run translate:compile && npm run dev",
"dx": "npm i && npm run dx:up && npm run prisma:migrate-dev && npm run prisma:seed", "dx": "npm i && npm run dx:up && npm run prisma:migrate-dev && npm run prisma:seed",
"dx:up": "docker compose -f docker/development/compose.yml up -d", "dx:up": "docker compose -f docker/development/compose.yml up -d",
"dx:down": "docker compose -f docker/development/compose.yml down", "dx:down": "docker compose -f docker/development/compose.yml down",

View File

@ -1,4 +1,4 @@
import { fetchRequestHandler } from '@ts-rest/serverless/fetch'; import { TsRestHttpError, fetchRequestHandler } from '@ts-rest/serverless/fetch';
import { Hono } from 'hono'; import { Hono } from 'hono';
import { ApiContractV1 } from '@documenso/api/v1/contract'; import { ApiContractV1 } from '@documenso/api/v1/contract';
@ -29,6 +29,12 @@ tsRestHonoApp.mount('/', async (request) => {
request, request,
contract: ApiContractV1, contract: ApiContractV1,
router: ApiContractV1Implementation, router: ApiContractV1Implementation,
options: {}, options: {
errorHandler: (err) => {
if (err instanceof TsRestHttpError && err.statusCode === 500) {
console.error(err);
}
},
},
}); });
}); });

View File

@ -1,3 +1,5 @@
import type { Prisma } from '@prisma/client';
import { DocumentDataType, SigningStatus, TeamMemberRole } from '@prisma/client';
import { tsr } from '@ts-rest/serverless/fetch'; import { tsr } from '@ts-rest/serverless/fetch';
import { match } from 'ts-pattern'; import { match } from 'ts-pattern';
@ -48,15 +50,9 @@ import {
getPresignGetUrl, getPresignGetUrl,
getPresignPostUrl, getPresignPostUrl,
} from '@documenso/lib/universal/upload/server-actions'; } from '@documenso/lib/universal/upload/server-actions';
import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs'; import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import type { Prisma } from '@documenso/prisma/client';
import {
DocumentDataType,
DocumentStatus,
SigningStatus,
TeamMemberRole,
} from '@documenso/prisma/client';
import { ApiContractV1 } from './contract'; import { ApiContractV1 } from './contract';
import { authenticatedMiddleware } from './middleware/authenticated'; import { authenticatedMiddleware } from './middleware/authenticated';
@ -181,7 +177,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
}; };
} }
if (document.status !== DocumentStatus.COMPLETED) { if (!isDocumentCompleted(document.status)) {
return { return {
status: 400, status: 400,
body: { body: {
@ -585,6 +581,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
userId: user.id, userId: user.id,
teamId: team?.id, teamId: team?.id,
recipients: body.recipients, recipients: body.recipients,
prefillFields: body.prefillFields,
override: { override: {
title: body.title, title: body.title,
...body.meta, ...body.meta,
@ -673,7 +670,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
}; };
} }
if (document.status === DocumentStatus.COMPLETED) { if (isDocumentCompleted(document.status)) {
return { return {
status: 400, status: 400,
body: { body: {
@ -776,7 +773,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
}; };
} }
if (document.status === DocumentStatus.COMPLETED) { if (isDocumentCompleted(document.status)) {
return { return {
status: 400, status: 400,
body: { body: {
@ -867,7 +864,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
}; };
} }
if (document.status === DocumentStatus.COMPLETED) { if (isDocumentCompleted(document.status)) {
return { return {
status: 400, status: 400,
body: { body: {
@ -926,7 +923,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
}; };
} }
if (document.status === DocumentStatus.COMPLETED) { if (isDocumentCompleted(document.status)) {
return { return {
status: 400, status: 400,
body: { body: {
@ -991,7 +988,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
}; };
} }
if (document.status === DocumentStatus.COMPLETED) { if (isDocumentCompleted(document.status)) {
return { return {
status: 400, status: 400,
body: { message: 'Document is already completed' }, body: { message: 'Document is already completed' },
@ -1153,7 +1150,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
}; };
} }
if (document.status === DocumentStatus.COMPLETED) { if (isDocumentCompleted(document.status)) {
return { return {
status: 400, status: 400,
body: { body: {
@ -1241,7 +1238,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
}; };
} }
if (document.status === DocumentStatus.COMPLETED) { if (isDocumentCompleted(document.status)) {
return { return {
status: 400, status: 400,
body: { body: {

View File

@ -1,10 +1,10 @@
import type { Team, User } from '@prisma/client';
import type { TsRestRequest } from '@ts-rest/serverless'; import type { TsRestRequest } from '@ts-rest/serverless';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { getApiTokenByToken } from '@documenso/lib/server-only/public-api/get-api-token-by-token'; import { getApiTokenByToken } from '@documenso/lib/server-only/public-api/get-api-token-by-token';
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { extractRequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; import { extractRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import type { Team, User } from '@documenso/prisma/client';
type B = { type B = {
// appRoute: any; // appRoute: any;

View File

@ -1,4 +1,16 @@
import { extendZodWithOpenApi } from '@anatine/zod-openapi'; import { extendZodWithOpenApi } from '@anatine/zod-openapi';
import {
DocumentDataType,
DocumentDistributionMethod,
DocumentSigningOrder,
FieldType,
ReadStatus,
RecipientRole,
SendStatus,
SigningStatus,
TeamMemberRole,
TemplateType,
} from '@prisma/client';
import { z } from 'zod'; import { z } from 'zod';
import { DATE_FORMATS, DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats'; import { DATE_FORMATS, DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
@ -11,19 +23,7 @@ import {
ZRecipientActionAuthTypesSchema, ZRecipientActionAuthTypesSchema,
} from '@documenso/lib/types/document-auth'; } from '@documenso/lib/types/document-auth';
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email'; import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
import { ZFieldMetaSchema } from '@documenso/lib/types/field-meta'; import { ZFieldMetaPrefillFieldsSchema, ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
import {
DocumentDataType,
DocumentDistributionMethod,
DocumentSigningOrder,
FieldType,
ReadStatus,
RecipientRole,
SendStatus,
SigningStatus,
TeamMemberRole,
TemplateType,
} from '@documenso/prisma/client';
extendZodWithOpenApi(z); extendZodWithOpenApi(z);
@ -96,7 +96,7 @@ export const ZSendDocumentForSigningMutationSchema = z
'Whether to send completion emails when the document is fully signed. This will override the document email settings.', 'Whether to send completion emails when the document is fully signed. This will override the document email settings.',
}), }),
}) })
.or(z.literal('').transform(() => ({ sendEmail: true, sendCompletionEmails: undefined }))); .or(z.any().transform(() => ({ sendEmail: true, sendCompletionEmails: undefined })));
export type TSendDocumentForSigningMutationSchema = typeof ZSendDocumentForSigningMutationSchema; export type TSendDocumentForSigningMutationSchema = typeof ZSendDocumentForSigningMutationSchema;
@ -299,6 +299,7 @@ export const ZGenerateDocumentFromTemplateMutationSchema = z.object({
}) })
.optional(), .optional(),
formValues: z.record(z.string(), z.union([z.string(), z.boolean(), z.number()])).optional(), formValues: z.record(z.string(), z.union([z.string(), z.boolean(), z.number()])).optional(),
prefillFields: z.array(ZFieldMetaPrefillFieldsSchema).optional(),
}); });
export type TGenerateDocumentFromTemplateMutationSchema = z.infer< export type TGenerateDocumentFromTemplateMutationSchema = z.infer<

View File

@ -1,4 +1,5 @@
import { expect, test } from '@playwright/test'; import { expect, test } from '@playwright/test';
import { TeamMemberRole } from '@prisma/client';
import { import {
ZFindTeamMembersResponseSchema, ZFindTeamMembersResponseSchema,
@ -10,7 +11,6 @@ import {
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token'; import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import { TeamMemberRole } from '@documenso/prisma/client';
import { seedTeam } from '@documenso/prisma/seed/teams'; import { seedTeam } from '@documenso/prisma/seed/teams';
import { seedUser } from '@documenso/prisma/seed/users'; import { seedUser } from '@documenso/prisma/seed/users';

View File

@ -0,0 +1,614 @@
import { expect, test } from '@playwright/test';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
import type { TCheckboxFieldMeta, TRadioFieldMeta } from '@documenso/lib/types/field-meta';
import { prisma } from '@documenso/prisma';
import { FieldType, RecipientRole } from '@documenso/prisma/client';
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../../fixtures/authentication';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
test.describe('Template Field Prefill API v1', () => {
test('should create a document from template with prefilled fields', async ({
page,
request,
}) => {
// 1. Create a user
const user = await seedUser();
// 2. Create an API token for the user
const { token } = await createApiToken({
userId: user.id,
tokenName: 'test-token',
expiresIn: null,
});
// 3. Create a template with seedBlankTemplate
const template = await seedBlankTemplate(user, {
createTemplateOptions: {
title: 'Template with Advanced Fields',
},
});
// 4. Create a recipient for the template
const recipient = await prisma.recipient.create({
data: {
templateId: template.id,
email: 'recipient@example.com',
name: 'Test Recipient',
role: RecipientRole.SIGNER,
token: 'test-token',
readStatus: 'NOT_OPENED',
sendStatus: 'NOT_SENT',
signingStatus: 'NOT_SIGNED',
},
});
// 5. Add fields to the template
// Add TEXT field
const textField = await prisma.field.create({
data: {
templateId: template.id,
recipientId: recipient.id,
type: FieldType.TEXT,
page: 1,
positionX: 5,
positionY: 5,
width: 10,
height: 5,
customText: '',
inserted: false,
fieldMeta: {
type: 'text',
label: 'Text Field',
},
},
});
// Add NUMBER field
const numberField = await prisma.field.create({
data: {
templateId: template.id,
recipientId: recipient.id,
type: FieldType.NUMBER,
page: 1,
positionX: 5,
positionY: 15,
width: 10,
height: 5,
customText: '',
inserted: false,
fieldMeta: {
type: 'number',
label: 'Number Field',
},
},
});
// Add RADIO field
const radioField = await prisma.field.create({
data: {
templateId: template.id,
recipientId: recipient.id,
type: FieldType.RADIO,
page: 1,
positionX: 5,
positionY: 25,
width: 10,
height: 5,
customText: '',
inserted: false,
fieldMeta: {
type: 'radio',
label: 'Radio Field',
values: [
{ id: 1, value: 'Option A', checked: false },
{ id: 2, value: 'Option B', checked: false },
],
},
},
});
// Add CHECKBOX field
const checkboxField = await prisma.field.create({
data: {
templateId: template.id,
recipientId: recipient.id,
type: FieldType.CHECKBOX,
page: 1,
positionX: 5,
positionY: 35,
width: 10,
height: 5,
customText: '',
inserted: false,
fieldMeta: {
type: 'checkbox',
label: 'Checkbox Field',
values: [
{ id: 1, value: 'Check A', checked: false },
{ id: 2, value: 'Check B', checked: false },
],
},
},
});
// Add DROPDOWN field
const dropdownField = await prisma.field.create({
data: {
templateId: template.id,
recipientId: recipient.id,
type: FieldType.DROPDOWN,
page: 1,
positionX: 5,
positionY: 45,
width: 10,
height: 5,
customText: '',
inserted: false,
fieldMeta: {
type: 'dropdown',
label: 'Dropdown Field',
values: [{ value: 'Select A' }, { value: 'Select B' }],
},
},
});
// 6. Sign in as the user
await apiSignin({
page,
email: user.email,
});
// 7. Navigate to the template
await page.goto(`${WEBAPP_BASE_URL}/templates/${template.id}`);
// 8. Create a document from the template with prefilled fields
const response = await request.post(
`${WEBAPP_BASE_URL}/api/v1/templates/${template.id}/generate-document`,
{
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
data: {
title: 'Document with Prefilled Fields',
recipients: [
{
id: recipient.id,
email: 'recipient@example.com',
name: 'Test Recipient',
role: 'SIGNER',
},
],
prefillFields: [
{
id: textField.id,
type: 'text',
label: 'Prefilled Text',
value: 'This is prefilled text',
},
{
id: numberField.id,
type: 'number',
label: 'Prefilled Number',
value: '42',
},
{
id: radioField.id,
type: 'radio',
label: 'Prefilled Radio',
value: 'Option A',
},
{
id: checkboxField.id,
type: 'checkbox',
label: 'Prefilled Checkbox',
value: ['Check A', 'Check B'],
},
{
id: dropdownField.id,
type: 'dropdown',
label: 'Prefilled Dropdown',
value: 'Select B',
},
],
},
},
);
const responseData = await response.json();
expect(response.ok()).toBeTruthy();
expect(response.status()).toBe(200);
expect(responseData.documentId).toBeDefined();
// 9. Verify the document was created with prefilled fields
const document = await prisma.document.findUnique({
where: {
id: responseData.documentId,
},
include: {
fields: true,
},
});
expect(document).not.toBeNull();
// 10. Verify each field has the correct prefilled values
const documentTextField = document?.fields.find(
(field) => field.type === FieldType.TEXT && field.fieldMeta?.type === 'text',
);
expect(documentTextField?.fieldMeta).toMatchObject({
type: 'text',
label: 'Prefilled Text',
text: 'This is prefilled text',
});
const documentNumberField = document?.fields.find(
(field) => field.type === FieldType.NUMBER && field.fieldMeta?.type === 'number',
);
expect(documentNumberField?.fieldMeta).toMatchObject({
type: 'number',
label: 'Prefilled Number',
value: '42',
});
const documentRadioField = document?.fields.find(
(field) => field.type === FieldType.RADIO && field.fieldMeta?.type === 'radio',
);
expect(documentRadioField?.fieldMeta).toMatchObject({
type: 'radio',
label: 'Prefilled Radio',
});
// Check that the correct radio option is selected
const radioValues = (documentRadioField?.fieldMeta as TRadioFieldMeta)?.values || [];
const selectedRadioOption = radioValues.find((option) => option.checked);
expect(selectedRadioOption?.value).toBe('Option A');
const documentCheckboxField = document?.fields.find(
(field) => field.type === FieldType.CHECKBOX && field.fieldMeta?.type === 'checkbox',
);
expect(documentCheckboxField?.fieldMeta).toMatchObject({
type: 'checkbox',
label: 'Prefilled Checkbox',
});
// Check that the correct checkbox options are selected
const checkboxValues = (documentCheckboxField?.fieldMeta as TCheckboxFieldMeta)?.values || [];
const checkedOptions = checkboxValues.filter((option) => option.checked);
expect(checkedOptions.length).toBe(2);
expect(checkedOptions.map((option) => option.value)).toContain('Check A');
expect(checkedOptions.map((option) => option.value)).toContain('Check B');
const documentDropdownField = document?.fields.find(
(field) => field.type === FieldType.DROPDOWN && field.fieldMeta?.type === 'dropdown',
);
expect(documentDropdownField?.fieldMeta).toMatchObject({
type: 'dropdown',
label: 'Prefilled Dropdown',
defaultValue: 'Select B',
});
// 11. Sign in as the recipient and verify the prefilled fields are visible
const documentRecipient = await prisma.recipient.findFirst({
where: {
documentId: document?.id,
email: 'recipient@example.com',
},
});
// Send the document to the recipient
const sendResponse = await request.post(
`${WEBAPP_BASE_URL}/api/v1/documents/${document?.id}/send`,
{
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
data: {
sendEmail: false,
},
},
);
expect(sendResponse.ok()).toBeTruthy();
expect(sendResponse.status()).toBe(200);
expect(documentRecipient).not.toBeNull();
// Visit the signing page
await page.goto(`${WEBAPP_BASE_URL}/sign/${documentRecipient?.token}`);
// Verify the prefilled fields are visible with correct values
// Text field
await expect(page.getByText('This is prefilled')).toBeVisible();
// Number field
await expect(page.getByText('42')).toBeVisible();
// Radio field
await expect(page.getByText('Option A')).toBeVisible();
await expect(page.getByRole('radio', { name: 'Option A' })).toBeChecked();
// Checkbox field
await expect(page.getByText('Check A')).toBeVisible();
await expect(page.getByText('Check B')).toBeVisible();
await expect(page.getByRole('checkbox', { name: 'Check A' })).toBeChecked();
await expect(page.getByRole('checkbox', { name: 'Check B' })).toBeChecked();
// Dropdown field
await expect(page.getByText('Select B')).toBeVisible();
});
test('should create a document from template without prefilled fields', async ({
page,
request,
}) => {
// 1. Create a user
const user = await seedUser();
// 2. Create an API token for the user
const { token } = await createApiToken({
userId: user.id,
tokenName: 'test-token',
expiresIn: null,
});
// 3. Create a template with seedBlankTemplate
const template = await seedBlankTemplate(user, {
createTemplateOptions: {
title: 'Template with Default Fields',
},
});
// 4. Create a recipient for the template
const recipient = await prisma.recipient.create({
data: {
templateId: template.id,
email: 'recipient@example.com',
name: 'Test Recipient',
role: RecipientRole.SIGNER,
token: 'test-token',
readStatus: 'NOT_OPENED',
sendStatus: 'NOT_SENT',
signingStatus: 'NOT_SIGNED',
},
});
// 5. Add fields to the template
// Add TEXT field
const textField = await prisma.field.create({
data: {
templateId: template.id,
recipientId: recipient.id,
type: FieldType.TEXT,
page: 1,
positionX: 5,
positionY: 5,
width: 10,
height: 5,
customText: '',
inserted: false,
fieldMeta: {
type: 'text',
label: 'Default Text Field',
},
},
});
// Add NUMBER field
const numberField = await prisma.field.create({
data: {
templateId: template.id,
recipientId: recipient.id,
type: FieldType.NUMBER,
page: 1,
positionX: 5,
positionY: 15,
width: 10,
height: 5,
customText: '',
inserted: false,
fieldMeta: {
type: 'number',
label: 'Default Number Field',
},
},
});
// 6. Sign in as the user
await apiSignin({
page,
email: user.email,
});
// 7. Navigate to the template
await page.goto(`${WEBAPP_BASE_URL}/templates/${template.id}`);
// 8. Create a document from the template without prefilled fields
const response = await request.post(
`${WEBAPP_BASE_URL}/api/v1/templates/${template.id}/generate-document`,
{
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
data: {
title: 'Document with Default Fields',
recipients: [
{
id: recipient.id,
email: 'recipient@example.com',
name: 'Test Recipient',
role: 'SIGNER',
},
],
},
},
);
const responseData = await response.json();
expect(response.ok()).toBeTruthy();
expect(response.status()).toBe(200);
expect(responseData.documentId).toBeDefined();
// 9. Verify the document was created with default fields
const document = await prisma.document.findUnique({
where: {
id: responseData.documentId,
},
include: {
fields: true,
},
});
expect(document).not.toBeNull();
// 10. Verify fields have their default values
const documentTextField = document?.fields.find((field) => field.type === FieldType.TEXT);
expect(documentTextField?.fieldMeta).toMatchObject({
type: 'text',
label: 'Default Text Field',
});
const documentNumberField = document?.fields.find((field) => field.type === FieldType.NUMBER);
expect(documentNumberField?.fieldMeta).toMatchObject({
type: 'number',
label: 'Default Number Field',
});
// 11. Sign in as the recipient and verify the default fields are visible
const documentRecipient = await prisma.recipient.findFirst({
where: {
documentId: document?.id,
email: 'recipient@example.com',
},
});
expect(documentRecipient).not.toBeNull();
const sendResponse = await request.post(
`${WEBAPP_BASE_URL}/api/v1/documents/${document?.id}/send`,
{
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
data: {
sendEmail: false,
},
},
);
expect(sendResponse.ok()).toBeTruthy();
expect(sendResponse.status()).toBe(200);
// Visit the signing page
await page.goto(`${WEBAPP_BASE_URL}/sign/${documentRecipient?.token}`);
// Verify the default fields are visible with correct labels
await expect(page.getByText('Default Text Field')).toBeVisible();
await expect(page.getByText('Default Number Field')).toBeVisible();
});
test('should handle invalid field prefill values', async ({ request }) => {
// 1. Create a user
const user = await seedUser();
// 2. Create an API token for the user
const { token } = await createApiToken({
userId: user.id,
tokenName: 'test-token',
expiresIn: null,
});
// 3. Create a template using seedBlankTemplate
const template = await seedBlankTemplate(user, {
createTemplateOptions: {
title: 'Template for Invalid Test',
visibility: 'EVERYONE',
},
});
// 4. Create a recipient for the template
const recipient = await prisma.recipient.create({
data: {
templateId: template.id,
email: 'recipient@example.com',
name: 'Test Recipient',
role: RecipientRole.SIGNER,
token: 'test-token',
readStatus: 'NOT_OPENED',
sendStatus: 'NOT_SENT',
signingStatus: 'NOT_SIGNED',
},
});
// 5. Add a field to the template
const field = await prisma.field.create({
data: {
templateId: template.id,
recipientId: recipient.id,
type: FieldType.RADIO,
page: 1,
positionX: 100,
positionY: 100,
width: 100,
height: 50,
customText: '',
inserted: false,
fieldMeta: {
type: 'radio',
label: 'Radio Field',
values: [
{ id: 1, value: 'Option A', checked: false },
{ id: 2, value: 'Option B', checked: false },
],
},
},
});
// 6. Try to create a document with invalid prefill value
const response = await request.post(
`${WEBAPP_BASE_URL}/api/v1/templates/${template.id}/generate-document`,
{
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
data: {
title: 'Document with Invalid Prefill',
recipients: [
{
id: recipient.id,
email: 'recipient@example.com',
name: 'Test Recipient',
role: 'SIGNER',
},
],
prefillFields: [
{
id: field.id,
type: 'radio',
label: 'Invalid Radio',
value: 'Non-existent Option', // This option doesn't exist
},
],
},
},
);
// 7. Verify the request fails with appropriate error
expect(response.ok()).toBeFalsy();
expect(response.status()).toBe(400);
const errorData = await response.json();
expect(errorData.message).toContain('not found in options for RADIO field');
});
});

View File

@ -0,0 +1,602 @@
import { expect, test } from '@playwright/test';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
import type { TCheckboxFieldMeta, TRadioFieldMeta } from '@documenso/lib/types/field-meta';
import { prisma } from '@documenso/prisma';
import { FieldType, RecipientRole } from '@documenso/prisma/client';
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../../fixtures/authentication';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
test.describe('Template Field Prefill API v2', () => {
test('should create a document from template with prefilled fields', async ({
page,
request,
}) => {
// 1. Create a user
const user = await seedUser();
// 2. Create an API token for the user
const { token } = await createApiToken({
userId: user.id,
tokenName: 'test-token',
expiresIn: null,
});
// 3. Create a template with seedBlankTemplate
const template = await seedBlankTemplate(user, {
createTemplateOptions: {
title: 'Template with Advanced Fields V2',
},
});
// 4. Create a recipient for the template
const recipient = await prisma.recipient.create({
data: {
templateId: template.id,
email: 'recipient@example.com',
name: 'Test Recipient',
role: RecipientRole.SIGNER,
token: 'test-token',
readStatus: 'NOT_OPENED',
sendStatus: 'NOT_SENT',
signingStatus: 'NOT_SIGNED',
},
});
// 5. Add fields to the template
// Add TEXT field
const textField = await prisma.field.create({
data: {
templateId: template.id,
recipientId: recipient.id,
type: FieldType.TEXT,
page: 1,
positionX: 5,
positionY: 5,
width: 20,
height: 5,
customText: '',
inserted: false,
fieldMeta: {
type: 'text',
label: 'Text Field',
},
},
});
// Add NUMBER field
const numberField = await prisma.field.create({
data: {
templateId: template.id,
recipientId: recipient.id,
type: FieldType.NUMBER,
page: 1,
positionX: 5,
positionY: 15,
width: 20,
height: 5,
customText: '',
inserted: false,
fieldMeta: {
type: 'number',
label: 'Number Field',
},
},
});
// Add RADIO field
const radioField = await prisma.field.create({
data: {
templateId: template.id,
recipientId: recipient.id,
type: FieldType.RADIO,
page: 1,
positionX: 5,
positionY: 25,
width: 20,
height: 5,
customText: '',
inserted: false,
fieldMeta: {
type: 'radio',
label: 'Radio Field',
values: [
{ id: 1, value: 'Option A', checked: false },
{ id: 2, value: 'Option B', checked: false },
],
},
},
});
// Add CHECKBOX field
const checkboxField = await prisma.field.create({
data: {
templateId: template.id,
recipientId: recipient.id,
type: FieldType.CHECKBOX,
page: 1,
positionX: 5,
positionY: 35,
width: 20,
height: 5,
customText: '',
inserted: false,
fieldMeta: {
type: 'checkbox',
label: 'Checkbox Field',
values: [
{ id: 1, value: 'Check A', checked: false },
{ id: 2, value: 'Check B', checked: false },
],
},
},
});
// Add DROPDOWN field
const dropdownField = await prisma.field.create({
data: {
templateId: template.id,
recipientId: recipient.id,
type: FieldType.DROPDOWN,
page: 1,
positionX: 5,
positionY: 45,
width: 20,
height: 5,
customText: '',
inserted: false,
fieldMeta: {
type: 'dropdown',
label: 'Dropdown Field',
values: [{ value: 'Select A' }, { value: 'Select B' }],
},
},
});
// 6. Sign in as the user
await apiSignin({
page,
email: user.email,
});
// 7. Navigate to the template
await page.goto(`${WEBAPP_BASE_URL}/templates/${template.id}`);
// 8. Create a document from the template with prefilled fields using v2 API
const response = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/template/use`, {
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
data: {
templateId: template.id,
recipients: [
{
id: recipient.id,
email: 'recipient@example.com',
name: 'Test Recipient',
},
],
prefillFields: [
{
id: textField.id,
type: 'text',
label: 'Prefilled Text',
value: 'This is prefilled text',
},
{
id: numberField.id,
type: 'number',
label: 'Prefilled Number',
value: '42',
},
{
id: radioField.id,
type: 'radio',
label: 'Prefilled Radio',
value: 'Option A',
},
{
id: checkboxField.id,
type: 'checkbox',
label: 'Prefilled Checkbox',
value: ['Check A', 'Check B'],
},
{
id: dropdownField.id,
type: 'dropdown',
label: 'Prefilled Dropdown',
value: 'Select B',
},
],
},
});
const responseData = await response.json();
expect(response.ok()).toBeTruthy();
expect(response.status()).toBe(200);
expect(responseData.id).toBeDefined();
// 9. Verify the document was created with prefilled fields
const document = await prisma.document.findUnique({
where: {
id: responseData.id,
},
include: {
fields: true,
},
});
expect(document).not.toBeNull();
// 10. Verify each field has the correct prefilled values
const documentTextField = document?.fields.find(
(field) => field.type === FieldType.TEXT && field.fieldMeta?.type === 'text',
);
expect(documentTextField?.fieldMeta).toMatchObject({
type: 'text',
label: 'Prefilled Text',
text: 'This is prefilled text',
});
const documentNumberField = document?.fields.find(
(field) => field.type === FieldType.NUMBER && field.fieldMeta?.type === 'number',
);
expect(documentNumberField?.fieldMeta).toMatchObject({
type: 'number',
label: 'Prefilled Number',
value: '42',
});
const documentRadioField = document?.fields.find(
(field) => field.type === FieldType.RADIO && field.fieldMeta?.type === 'radio',
);
expect(documentRadioField?.fieldMeta).toMatchObject({
type: 'radio',
label: 'Prefilled Radio',
});
// Check that the correct radio option is selected
const radioValues = (documentRadioField?.fieldMeta as TRadioFieldMeta)?.values || [];
const selectedRadioOption = radioValues.find((option) => option.checked);
expect(selectedRadioOption?.value).toBe('Option A');
const documentCheckboxField = document?.fields.find(
(field) => field.type === FieldType.CHECKBOX && field.fieldMeta?.type === 'checkbox',
);
expect(documentCheckboxField?.fieldMeta).toMatchObject({
type: 'checkbox',
label: 'Prefilled Checkbox',
});
// Check that the correct checkbox options are selected
const checkboxValues = (documentCheckboxField?.fieldMeta as TCheckboxFieldMeta)?.values || [];
const checkedOptions = checkboxValues.filter((option) => option.checked);
expect(checkedOptions.length).toBe(2);
expect(checkedOptions.map((option) => option.value)).toContain('Check A');
expect(checkedOptions.map((option) => option.value)).toContain('Check B');
const documentDropdownField = document?.fields.find(
(field) => field.type === FieldType.DROPDOWN && field.fieldMeta?.type === 'dropdown',
);
expect(documentDropdownField?.fieldMeta).toMatchObject({
type: 'dropdown',
label: 'Prefilled Dropdown',
defaultValue: 'Select B',
});
const sendResponse = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/document/distribute`, {
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
data: {
documentId: document?.id,
meta: {
subject: 'Test Subject',
message: 'Test Message',
},
},
});
await expect(sendResponse.ok()).toBeTruthy();
await expect(sendResponse.status()).toBe(200);
// 11. Sign in as the recipient and verify the prefilled fields are visible
const documentRecipient = await prisma.recipient.findFirst({
where: {
documentId: document?.id,
email: 'recipient@example.com',
},
});
expect(documentRecipient).not.toBeNull();
// Visit the signing page
await page.goto(`${WEBAPP_BASE_URL}/sign/${documentRecipient?.token}`);
// Verify the prefilled fields are visible with correct values
// Text field
await expect(page.getByText('This is prefilled')).toBeVisible();
// Number field
await expect(page.getByText('42')).toBeVisible();
// Radio field
await expect(page.getByText('Option A')).toBeVisible();
await expect(page.getByRole('radio', { name: 'Option A' })).toBeChecked();
// Checkbox field
await expect(page.getByText('Check A')).toBeVisible();
await expect(page.getByText('Check B')).toBeVisible();
await expect(page.getByRole('checkbox', { name: 'Check A' })).toBeChecked();
await expect(page.getByRole('checkbox', { name: 'Check B' })).toBeChecked();
// Dropdown field
await expect(page.getByText('Select B')).toBeVisible();
});
test('should create a document from template without prefilled fields', async ({
page,
request,
}) => {
// 1. Create a user
const user = await seedUser();
// 2. Create an API token for the user
const { token } = await createApiToken({
userId: user.id,
tokenName: 'test-token',
expiresIn: null,
});
// 3. Create a template with seedBlankTemplate
const template = await seedBlankTemplate(user, {
createTemplateOptions: {
title: 'Template with Default Fields V2',
},
});
// 4. Create a recipient for the template
const recipient = await prisma.recipient.create({
data: {
templateId: template.id,
email: 'recipient@example.com',
name: 'Test Recipient',
role: RecipientRole.SIGNER,
token: 'test-token',
readStatus: 'NOT_OPENED',
sendStatus: 'NOT_SENT',
signingStatus: 'NOT_SIGNED',
},
});
// 5. Add fields to the template
// Add TEXT field
const textField = await prisma.field.create({
data: {
templateId: template.id,
recipientId: recipient.id,
type: FieldType.TEXT,
page: 1,
positionX: 5,
positionY: 5,
width: 20,
height: 5,
customText: '',
inserted: false,
fieldMeta: {
type: 'text',
label: 'Default Text Field',
},
},
});
// Add NUMBER field
const numberField = await prisma.field.create({
data: {
templateId: template.id,
recipientId: recipient.id,
type: FieldType.NUMBER,
page: 1,
positionX: 5,
positionY: 15,
width: 20,
height: 5,
customText: '',
inserted: false,
fieldMeta: {
type: 'number',
label: 'Default Number Field',
},
},
});
// 6. Sign in as the user
await apiSignin({
page,
email: user.email,
});
// 7. Navigate to the template
await page.goto(`${WEBAPP_BASE_URL}/templates/${template.id}`);
// 8. Create a document from the template without prefilled fields using v2 API
const response = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/template/use`, {
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
data: {
templateId: template.id,
recipients: [
{
id: recipient.id,
email: 'recipient@example.com',
name: 'Test Recipient',
},
],
},
});
const responseData = await response.json();
expect(response.ok()).toBeTruthy();
expect(response.status()).toBe(200);
expect(responseData.id).toBeDefined();
// 9. Verify the document was created with default fields
const document = await prisma.document.findUnique({
where: {
id: responseData.id,
},
include: {
fields: true,
},
});
expect(document).not.toBeNull();
// 10. Verify fields have their default values
const documentTextField = document?.fields.find((field) => field.type === FieldType.TEXT);
expect(documentTextField?.fieldMeta).toMatchObject({
type: 'text',
label: 'Default Text Field',
});
const documentNumberField = document?.fields.find((field) => field.type === FieldType.NUMBER);
expect(documentNumberField?.fieldMeta).toMatchObject({
type: 'number',
label: 'Default Number Field',
});
const sendResponse = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/document/distribute`, {
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
data: {
documentId: document?.id,
meta: {
subject: 'Test Subject',
message: 'Test Message',
},
},
});
await expect(sendResponse.ok()).toBeTruthy();
await expect(sendResponse.status()).toBe(200);
// 11. Sign in as the recipient and verify the default fields are visible
const documentRecipient = await prisma.recipient.findFirst({
where: {
documentId: document?.id,
email: 'recipient@example.com',
},
});
expect(documentRecipient).not.toBeNull();
// Visit the signing page
await page.goto(`${WEBAPP_BASE_URL}/sign/${documentRecipient?.token}`);
await expect(page.getByText('This is prefilled')).not.toBeVisible();
});
test('should handle invalid field prefill values', async ({ request }) => {
// 1. Create a user
const user = await seedUser();
// 2. Create an API token for the user
const { token } = await createApiToken({
userId: user.id,
tokenName: 'test-token',
expiresIn: null,
});
// 3. Create a template using seedBlankTemplate
const template = await seedBlankTemplate(user, {
createTemplateOptions: {
title: 'Template for Invalid Test V2',
visibility: 'EVERYONE',
},
});
// 4. Create a recipient for the template
const recipient = await prisma.recipient.create({
data: {
templateId: template.id,
email: 'recipient@example.com',
name: 'Test Recipient',
role: RecipientRole.SIGNER,
token: 'test-token',
readStatus: 'NOT_OPENED',
sendStatus: 'NOT_SENT',
signingStatus: 'NOT_SIGNED',
},
});
// 5. Add a field to the template
const field = await prisma.field.create({
data: {
templateId: template.id,
recipientId: recipient.id,
type: FieldType.RADIO,
page: 1,
positionX: 100,
positionY: 100,
width: 100,
height: 50,
customText: '',
inserted: false,
fieldMeta: {
type: 'radio',
label: 'Radio Field',
values: [
{ id: 1, value: 'Option A', checked: false },
{ id: 2, value: 'Option B', checked: false },
],
},
},
});
// 7. Try to create a document with invalid prefill value
const response = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/template/use`, {
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
data: {
templateId: template.id,
recipients: [
{
id: recipient.id,
email: 'recipient@example.com',
name: 'Test Recipient',
},
],
prefillFields: [
{
id: field.id,
type: 'radio',
label: 'Invalid Radio',
value: 'Non-existent Option', // This option doesn't exist
},
],
},
});
// 8. Verify the request fails with appropriate error
expect(response.ok()).toBeFalsy();
expect(response.status()).toBe(400);
const errorData = await response.json();
expect(errorData.message).toContain('not found in options for RADIO field');
});
});

View File

@ -1,11 +1,11 @@
import { expect, test } from '@playwright/test'; import { expect, test } from '@playwright/test';
import { FieldType } from '@prisma/client';
import { ZRecipientAuthOptionsSchema } from '@documenso/lib/types/document-auth'; import { ZRecipientAuthOptionsSchema } from '@documenso/lib/types/document-auth';
import { import {
createDocumentAuthOptions, createDocumentAuthOptions,
createRecipientAuthOptions, createRecipientAuthOptions,
} from '@documenso/lib/utils/document-auth'; } from '@documenso/lib/utils/document-auth';
import { FieldType } from '@documenso/prisma/client';
import { import {
seedPendingDocumentNoFields, seedPendingDocumentNoFields,
seedPendingDocumentWithFullFields, seedPendingDocumentWithFullFields,

View File

@ -1,16 +1,16 @@
import { expect, test } from '@playwright/test'; import { expect, test } from '@playwright/test';
import { DateTime } from 'luxon';
import path from 'node:path';
import { getRecipientByEmail } from '@documenso/lib/server-only/recipient/get-recipient-by-email';
import { prisma } from '@documenso/prisma';
import { import {
DocumentSigningOrder, DocumentSigningOrder,
DocumentStatus, DocumentStatus,
FieldType, FieldType,
RecipientRole, RecipientRole,
SigningStatus, SigningStatus,
} from '@documenso/prisma/client'; } from '@prisma/client';
import { DateTime } from 'luxon';
import path from 'node:path';
import { getRecipientByEmail } from '@documenso/lib/server-only/recipient/get-recipient-by-email';
import { prisma } from '@documenso/prisma';
import { import {
seedBlankDocument, seedBlankDocument,
seedPendingDocumentWithFullFields, seedPendingDocumentWithFullFields,
@ -377,7 +377,9 @@ test('[DOCUMENT_FLOW]: should be able to approve a document', async ({ page }) =
await expect(page.locator(`#field-${field.id}`)).toHaveAttribute('data-inserted', 'true'); await expect(page.locator(`#field-${field.id}`)).toHaveAttribute('data-inserted', 'true');
} }
await page.getByRole('button', { name: 'Complete' }).click(); await page
.getByRole('button', { name: role === RecipientRole.SIGNER ? 'Complete' : 'Approve' })
.click();
await page await page
.getByRole('button', { name: role === RecipientRole.SIGNER ? 'Sign' : 'Approve' }) .getByRole('button', { name: role === RecipientRole.SIGNER ? 'Sign' : 'Approve' })
.click(); .click();
@ -447,7 +449,7 @@ test('[DOCUMENT_FLOW]: should be able to create, send with redirect url, sign a
const { status } = await getDocumentByToken(token); const { status } = await getDocumentByToken(token);
expect(status).toBe(DocumentStatus.PENDING); expect(status).toBe(DocumentStatus.PENDING);
await page.getByRole('button', { name: 'Complete' }).click(); await page.getByRole('button', { name: 'Approve' }).click();
await expect(page.getByRole('dialog').getByText('Complete Approval').first()).toBeVisible(); await expect(page.getByRole('dialog').getByText('Complete Approval').first()).toBeVisible();
await page.getByRole('button', { name: 'Approve' }).click(); await page.getByRole('button', { name: 'Approve' }).click();

View File

@ -1,10 +1,10 @@
import { expect, test } from '@playwright/test'; import { expect, test } from '@playwright/test';
import { DocumentStatus, FieldType } from '@prisma/client';
import { PDFDocument } from 'pdf-lib'; import { PDFDocument } from 'pdf-lib';
import { getDocumentByToken } from '@documenso/lib/server-only/document/get-document-by-token'; import { getDocumentByToken } from '@documenso/lib/server-only/document/get-document-by-token';
import { getFile } from '@documenso/lib/universal/upload/get-file'; import { getFile } from '@documenso/lib/universal/upload/get-file';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import { DocumentStatus, FieldType } from '@documenso/prisma/client';
import { seedPendingDocumentWithFullFields } from '@documenso/prisma/seed/documents'; import { seedPendingDocumentWithFullFields } from '@documenso/prisma/seed/documents';
import { seedTeam } from '@documenso/prisma/seed/teams'; import { seedTeam } from '@documenso/prisma/seed/teams';
import { seedUser } from '@documenso/prisma/seed/users'; import { seedUser } from '@documenso/prisma/seed/users';

View File

@ -1,6 +1,6 @@
import { expect, test } from '@playwright/test'; import { expect, test } from '@playwright/test';
import { DocumentStatus, TeamMemberRole } from '@prisma/client';
import { DocumentStatus, TeamMemberRole } from '@documenso/prisma/client';
import { seedDocuments, seedTeamDocuments } from '@documenso/prisma/seed/documents'; import { seedDocuments, seedTeamDocuments } from '@documenso/prisma/seed/documents';
import { seedTeam, seedTeamMember } from '@documenso/prisma/seed/teams'; import { seedTeam, seedTeamMember } from '@documenso/prisma/seed/teams';
import { seedUser } from '@documenso/prisma/seed/users'; import { seedUser } from '@documenso/prisma/seed/users';

View File

@ -1,6 +1,6 @@
import { expect, test } from '@playwright/test'; import { expect, test } from '@playwright/test';
import { DocumentStatus, DocumentVisibility, TeamMemberRole } from '@prisma/client';
import { DocumentStatus, DocumentVisibility, TeamMemberRole } from '@documenso/prisma/client';
import { seedBlankDocument } from '@documenso/prisma/seed/documents'; import { seedBlankDocument } from '@documenso/prisma/seed/documents';
import { seedDocuments, seedTeamDocuments } from '@documenso/prisma/seed/documents'; import { seedDocuments, seedTeamDocuments } from '@documenso/prisma/seed/documents';
import { seedTeam, seedTeamEmail, seedTeamMember } from '@documenso/prisma/seed/teams'; import { seedTeam, seedTeamEmail, seedTeamMember } from '@documenso/prisma/seed/teams';

View File

@ -1,7 +1,7 @@
import { expect, test } from '@playwright/test'; import { expect, test } from '@playwright/test';
import { TeamMemberRole } from '@prisma/client';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import { TeamMemberRole } from '@documenso/prisma/client';
import { seedUserSubscription } from '@documenso/prisma/seed/subscriptions'; import { seedUserSubscription } from '@documenso/prisma/seed/subscriptions';
import { seedTeam } from '@documenso/prisma/seed/teams'; import { seedTeam } from '@documenso/prisma/seed/teams';
import { seedBlankTemplate } from '@documenso/prisma/seed/templates'; import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
@ -12,7 +12,7 @@ import { apiSignin } from '../fixtures/authentication';
test.describe.configure({ mode: 'parallel' }); test.describe.configure({ mode: 'parallel' });
test.describe('[EE_ONLY]', () => { test.describe('[EE_ONLY]', () => {
const enterprisePriceId = process.env.NEXT_PUBLIC_STRIPE_ENTERPRISE_PLAN_MONTHLY_PRICE_ID || ''; const enterprisePriceId = '';
test.beforeEach(() => { test.beforeEach(() => {
test.skip( test.skip(

View File

@ -1,11 +1,11 @@
import { expect, test } from '@playwright/test'; import { expect, test } from '@playwright/test';
import { DocumentDataType, TeamMemberRole } from '@prisma/client';
import fs from 'fs'; import fs from 'fs';
import os from 'os'; import os from 'os';
import path from 'path'; import path from 'path';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth'; import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import { DocumentDataType, TeamMemberRole } from '@documenso/prisma/client';
import { seedUserSubscription } from '@documenso/prisma/seed/subscriptions'; import { seedUserSubscription } from '@documenso/prisma/seed/subscriptions';
import { seedTeam } from '@documenso/prisma/seed/teams'; import { seedTeam } from '@documenso/prisma/seed/teams';
import { seedBlankTemplate } from '@documenso/prisma/seed/templates'; import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
@ -15,7 +15,7 @@ import { apiSignin } from '../fixtures/authentication';
test.describe.configure({ mode: 'parallel' }); test.describe.configure({ mode: 'parallel' });
const enterprisePriceId = process.env.NEXT_PUBLIC_STRIPE_ENTERPRISE_PLAN_MONTHLY_PRICE_ID || ''; const enterprisePriceId = '';
// Create a temporary PDF file for testing // Create a temporary PDF file for testing
function createTempPdfFile() { function createTempPdfFile() {

View File

@ -1,3 +1,4 @@
import { UserSecurityAuditLogType } from '@prisma/client';
import { OAuth2Client, decodeIdToken } from 'arctic'; import { OAuth2Client, decodeIdToken } from 'arctic';
import type { Context } from 'hono'; import type { Context } from 'hono';
import { deleteCookie } from 'hono/cookie'; import { deleteCookie } from 'hono/cookie';
@ -6,7 +7,6 @@ import { nanoid } from 'nanoid';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { onCreateUserHook } from '@documenso/lib/server-only/user/create-user'; import { onCreateUserHook } from '@documenso/lib/server-only/user/create-user';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import { UserSecurityAuditLogType } from '@documenso/prisma/client';
import type { OAuthClientOptions } from '../../config'; import type { OAuthClientOptions } from '../../config';
import { AuthenticationErrorCode } from '../errors/error-codes'; import { AuthenticationErrorCode } from '../errors/error-codes';

View File

@ -1,5 +1,6 @@
import { sValidator } from '@hono/standard-validator'; import { sValidator } from '@hono/standard-validator';
import { compare } from '@node-rs/bcrypt'; import { compare } from '@node-rs/bcrypt';
import { UserSecurityAuditLogType } from '@prisma/client';
import { Hono } from 'hono'; import { Hono } from 'hono';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { z } from 'zod'; import { z } from 'zod';
@ -22,7 +23,6 @@ import { updatePassword } from '@documenso/lib/server-only/user/update-password'
import { verifyEmail } from '@documenso/lib/server-only/user/verify-email'; import { verifyEmail } from '@documenso/lib/server-only/user/verify-email';
import { env } from '@documenso/lib/utils/env'; import { env } from '@documenso/lib/utils/env';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import { UserSecurityAuditLogType } from '@documenso/prisma/client';
import { AuthenticationErrorCode } from '../lib/errors/error-codes'; import { AuthenticationErrorCode } from '../lib/errors/error-codes';
import { getCsrfCookie } from '../lib/session/session-cookies'; import { getCsrfCookie } from '../lib/session/session-cookies';

View File

@ -1,8 +1,8 @@
import { DocumentSource, SubscriptionStatus } from '@prisma/client';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app'; import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import { DocumentSource, SubscriptionStatus } from '@documenso/prisma/client';
import { getDocumentRelatedPrices } from '../stripe/get-document-related-prices.ts'; import { getDocumentRelatedPrices } from '../stripe/get-document-related-prices.ts';
import { FREE_PLAN_LIMITS, SELFHOSTED_PLAN_LIMITS, TEAM_PLAN_LIMITS } from './constants'; import { FREE_PLAN_LIMITS, SELFHOSTED_PLAN_LIMITS, TEAM_PLAN_LIMITS } from './constants';

View File

@ -1,7 +1,8 @@
import type { User } from '@prisma/client';
import { STRIPE_CUSTOMER_TYPE } from '@documenso/lib/constants/billing'; import { STRIPE_CUSTOMER_TYPE } from '@documenso/lib/constants/billing';
import { stripe } from '@documenso/lib/server-only/stripe'; import { stripe } from '@documenso/lib/server-only/stripe';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import type { User } from '@documenso/prisma/client';
import { onSubscriptionUpdated } from './webhook/on-subscription-updated'; import { onSubscriptionUpdated } from './webhook/on-subscription-updated';

View File

@ -4,15 +4,14 @@ import { stripe } from '@documenso/lib/server-only/stripe';
type PlanType = (typeof STRIPE_PLAN_TYPE)[keyof typeof STRIPE_PLAN_TYPE]; type PlanType = (typeof STRIPE_PLAN_TYPE)[keyof typeof STRIPE_PLAN_TYPE];
export const getPricesByPlan = async (plan: PlanType | PlanType[]) => { export const getPricesByPlan = async (plan: PlanType | PlanType[]) => {
const planTypes = typeof plan === 'string' ? [plan] : plan; const planTypes: string[] = typeof plan === 'string' ? [plan] : plan;
const query = planTypes.map((planType) => `metadata['plan']:'${planType}'`).join(' OR '); const prices = await stripe.prices.list({
const { data: prices } = await stripe.prices.search({
query,
expand: ['data.product'], expand: ['data.product'],
limit: 100, limit: 100,
}); });
return prices.filter((price) => price.type === 'recurring'); return prices.data.filter(
(price) => price.type === 'recurring' && planTypes.includes(price.metadata.plan),
);
}; };

View File

@ -1,10 +1,10 @@
import { type Subscription, type Team, type User } from '@prisma/client';
import type Stripe from 'stripe'; import type Stripe from 'stripe';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { stripe } from '@documenso/lib/server-only/stripe'; import { stripe } from '@documenso/lib/server-only/stripe';
import { subscriptionsContainsActivePlan } from '@documenso/lib/utils/billing'; import { subscriptionsContainsActivePlan } from '@documenso/lib/utils/billing';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import { type Subscription, type Team, type User } from '@documenso/prisma/client';
import { deleteCustomerPaymentMethods } from './delete-customer-payment-methods'; import { deleteCustomerPaymentMethods } from './delete-customer-payment-methods';
import { getTeamPrices } from './get-team-prices'; import { getTeamPrices } from './get-team-prices';

View File

@ -1,6 +1,7 @@
import { SubscriptionStatus } from '@prisma/client';
import type { Stripe } from '@documenso/lib/server-only/stripe'; import type { Stripe } from '@documenso/lib/server-only/stripe';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import { SubscriptionStatus } from '@documenso/prisma/client';
export type OnSubscriptionDeletedOptions = { export type OnSubscriptionDeletedOptions = {
subscription: Stripe.Subscription; subscription: Stripe.Subscription;

View File

@ -1,9 +1,9 @@
import type { Prisma } from '@prisma/client';
import { SubscriptionStatus } from '@prisma/client';
import { match } from 'ts-pattern'; import { match } from 'ts-pattern';
import type { Stripe } from '@documenso/lib/server-only/stripe'; import type { Stripe } from '@documenso/lib/server-only/stripe';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import type { Prisma } from '@documenso/prisma/client';
import { SubscriptionStatus } from '@documenso/prisma/client';
export type OnSubscriptionUpdatedOptions = { export type OnSubscriptionUpdatedOptions = {
userId?: number; userId?: number;

View File

@ -1,6 +1,7 @@
import type { Subscription } from '@prisma/client';
import { subscriptionsContainsActivePlan } from '@documenso/lib/utils/billing'; import { subscriptionsContainsActivePlan } from '@documenso/lib/utils/billing';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import type { Subscription } from '@documenso/prisma/client';
import { getCommunityPlanPriceIds } from '../stripe/get-community-plan-prices'; import { getCommunityPlanPriceIds } from '../stripe/get-community-plan-prices';

View File

@ -1,7 +1,8 @@
import type { Subscription } from '@prisma/client';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app'; import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { subscriptionsContainsActivePlan } from '@documenso/lib/utils/billing'; import { subscriptionsContainsActivePlan } from '@documenso/lib/utils/billing';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import type { Subscription } from '@documenso/prisma/client';
import { getEnterprisePlanPriceIds } from '../stripe/get-enterprise-plan-prices'; import { getEnterprisePlanPriceIds } from '../stripe/get-enterprise-plan-prices';

View File

@ -1,7 +1,8 @@
import type { Document, Subscription } from '@prisma/client';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app'; import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { subscriptionsContainsActivePlan } from '@documenso/lib/utils/billing'; import { subscriptionsContainsActivePlan } from '@documenso/lib/utils/billing';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import type { Document, Subscription } from '@documenso/prisma/client';
import { getPlatformPlanPriceIds } from '../stripe/get-platform-plan-prices'; import { getPlatformPlanPriceIds } from '../stripe/get-platform-plan-prices';

View File

@ -8,12 +8,14 @@ export interface TemplateDocumentCancelProps {
inviterEmail: string; inviterEmail: string;
documentName: string; documentName: string;
assetBaseUrl: string; assetBaseUrl: string;
cancellationReason?: string;
} }
export const TemplateDocumentCancel = ({ export const TemplateDocumentCancel = ({
inviterName, inviterName,
documentName, documentName,
assetBaseUrl, assetBaseUrl,
cancellationReason,
}: TemplateDocumentCancelProps) => { }: TemplateDocumentCancelProps) => {
return ( return (
<> <>
@ -34,6 +36,12 @@ export const TemplateDocumentCancel = ({
<Text className="my-1 text-center text-base text-slate-400"> <Text className="my-1 text-center text-base text-slate-400">
<Trans>You don't need to sign it anymore.</Trans> <Trans>You don't need to sign it anymore.</Trans>
</Text> </Text>
{cancellationReason && (
<Text className="mt-4 text-center text-base">
<Trans>Reason for cancellation: {cancellationReason}</Trans>
</Text>
)}
</Section> </Section>
</> </>
); );

View File

@ -2,10 +2,10 @@ import { useMemo } from 'react';
import { useLingui } from '@lingui/react'; import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import { RecipientRole } from '@prisma/client';
import { P, match } from 'ts-pattern'; import { P, match } from 'ts-pattern';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles'; import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import { RecipientRole } from '@documenso/prisma/client';
import { Button, Section, Text } from '../components'; import { Button, Section, Text } from '../components';
import { TemplateDocumentImage } from './template-document-image'; import { TemplateDocumentImage } from './template-document-image';

View File

@ -14,6 +14,7 @@ export const DocumentCancelTemplate = ({
inviterEmail = 'lucas@documenso.com', inviterEmail = 'lucas@documenso.com',
documentName = 'Open Source Pledge.pdf', documentName = 'Open Source Pledge.pdf',
assetBaseUrl = 'http://localhost:3002', assetBaseUrl = 'http://localhost:3002',
cancellationReason,
}: DocumentCancelEmailTemplateProps) => { }: DocumentCancelEmailTemplateProps) => {
const { _ } = useLingui(); const { _ } = useLingui();
const branding = useBranding(); const branding = useBranding();
@ -48,6 +49,7 @@ export const DocumentCancelTemplate = ({
inviterEmail={inviterEmail} inviterEmail={inviterEmail}
documentName={documentName} documentName={documentName}
assetBaseUrl={assetBaseUrl} assetBaseUrl={assetBaseUrl}
cancellationReason={cancellationReason}
/> />
</Section> </Section>
</Container> </Container>

View File

@ -1,9 +1,9 @@
import { msg } from '@lingui/core/macro'; import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react'; import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import { RecipientRole } from '@prisma/client';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles'; import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import { RecipientRole } from '@documenso/prisma/client';
import { Body, Button, Container, Head, Html, Img, Preview, Section, Text } from '../components'; import { Body, Button, Container, Head, Html, Img, Preview, Section, Text } from '../components';
import { useBranding } from '../providers/branding'; import { useBranding } from '../providers/branding';

View File

@ -1,9 +1,9 @@
import { msg } from '@lingui/core/macro'; import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react'; import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import type { RecipientRole } from '@prisma/client';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles'; import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import type { RecipientRole } from '@documenso/prisma/client';
import { Body, Container, Head, Hr, Html, Img, Link, Preview, Section, Text } from '../components'; import { Body, Container, Head, Hr, Html, Img, Link, Preview, Section, Text } from '../components';
import { useBranding } from '../providers/branding'; import { useBranding } from '../providers/branding';

View File

@ -1,12 +1,12 @@
import { createContext, useCallback, useContext, useEffect, useState } from 'react'; import { createContext, useCallback, useContext, useEffect, useState } from 'react';
import React from 'react'; import React from 'react';
import type { Session } from '@prisma/client';
import { useLocation } from 'react-router'; import { useLocation } from 'react-router';
import { authClient } from '@documenso/auth/client'; import { authClient } from '@documenso/auth/client';
import type { SessionUser } from '@documenso/auth/server/lib/session/session'; import type { SessionUser } from '@documenso/auth/server/lib/session/session';
import { type TGetTeamsResponse } from '@documenso/lib/server-only/team/get-teams'; import { type TGetTeamsResponse } from '@documenso/lib/server-only/team/get-teams';
import type { Session } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/client'; import { trpc } from '@documenso/trpc/client';
export type AppSession = { export type AppSession = {

View File

@ -8,6 +8,9 @@ export const DOCUMENT_STATUS: {
[DocumentStatus.COMPLETED]: { [DocumentStatus.COMPLETED]: {
description: msg`Completed`, description: msg`Completed`,
}, },
[DocumentStatus.REJECTED]: {
description: msg`Rejected`,
},
[DocumentStatus.DRAFT]: { [DocumentStatus.DRAFT]: {
description: msg`Draft`, description: msg`Draft`,
}, },

View File

@ -1,5 +1,6 @@
import { JobClient } from './client/client'; import { JobClient } from './client/client';
import { SEND_CONFIRMATION_EMAIL_JOB_DEFINITION } from './definitions/emails/send-confirmation-email'; import { SEND_CONFIRMATION_EMAIL_JOB_DEFINITION } from './definitions/emails/send-confirmation-email';
import { SEND_DOCUMENT_CANCELLED_EMAILS_JOB_DEFINITION } from './definitions/emails/send-document-cancelled-emails';
import { SEND_PASSWORD_RESET_SUCCESS_EMAIL_JOB_DEFINITION } from './definitions/emails/send-password-reset-success-email'; import { SEND_PASSWORD_RESET_SUCCESS_EMAIL_JOB_DEFINITION } from './definitions/emails/send-password-reset-success-email';
import { SEND_RECIPIENT_SIGNED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-recipient-signed-email'; import { SEND_RECIPIENT_SIGNED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-recipient-signed-email';
import { SEND_SIGNING_REJECTION_EMAILS_JOB_DEFINITION } from './definitions/emails/send-rejection-emails'; import { SEND_SIGNING_REJECTION_EMAILS_JOB_DEFINITION } from './definitions/emails/send-rejection-emails';
@ -24,6 +25,7 @@ export const jobsClient = new JobClient([
SEND_PASSWORD_RESET_SUCCESS_EMAIL_JOB_DEFINITION, SEND_PASSWORD_RESET_SUCCESS_EMAIL_JOB_DEFINITION,
SEND_SIGNING_REJECTION_EMAILS_JOB_DEFINITION, SEND_SIGNING_REJECTION_EMAILS_JOB_DEFINITION,
SEND_RECIPIENT_SIGNED_EMAIL_JOB_DEFINITION, SEND_RECIPIENT_SIGNED_EMAIL_JOB_DEFINITION,
SEND_DOCUMENT_CANCELLED_EMAILS_JOB_DEFINITION,
BULK_SEND_TEMPLATE_JOB_DEFINITION, BULK_SEND_TEMPLATE_JOB_DEFINITION,
] as const); ] as const);

View File

@ -0,0 +1,105 @@
import { createElement } from 'react';
import { msg } from '@lingui/core/macro';
import { ReadStatus, SendStatus, SigningStatus } from '@prisma/client';
import { mailer } from '@documenso/email/mailer';
import DocumentCancelTemplate from '@documenso/email/templates/document-cancel';
import { prisma } from '@documenso/prisma';
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
import { FROM_ADDRESS, FROM_NAME } from '../../../constants/email';
import { extractDerivedDocumentEmailSettings } from '../../../types/document-email';
import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
import { teamGlobalSettingsToBranding } from '../../../utils/team-global-settings-to-branding';
import type { JobRunIO } from '../../client/_internal/job';
import type { TSendDocumentCancelledEmailsJobDefinition } from './send-document-cancelled-emails';
export const run = async ({
payload,
io,
}: {
payload: TSendDocumentCancelledEmailsJobDefinition;
io: JobRunIO;
}) => {
const { documentId, cancellationReason } = payload;
const document = await prisma.document.findFirstOrThrow({
where: {
id: documentId,
},
include: {
user: true,
documentMeta: true,
recipients: true,
team: {
select: {
teamEmail: true,
name: true,
url: true,
teamGlobalSettings: true,
},
},
},
});
const { documentMeta, user: documentOwner } = document;
// Check if document cancellation emails are enabled
const isEmailEnabled = extractDerivedDocumentEmailSettings(documentMeta).documentDeleted;
if (!isEmailEnabled) {
return;
}
const i18n = await getI18nInstance(documentMeta?.language);
// Send cancellation emails to all recipients who have been sent the document or viewed it
const recipientsToNotify = document.recipients.filter(
(recipient) =>
(recipient.sendStatus === SendStatus.SENT || recipient.readStatus === ReadStatus.OPENED) &&
recipient.signingStatus !== SigningStatus.REJECTED,
);
await io.runTask('send-cancellation-emails', async () => {
await Promise.all(
recipientsToNotify.map(async (recipient) => {
const template = createElement(DocumentCancelTemplate, {
documentName: document.title,
inviterName: documentOwner.name || undefined,
inviterEmail: documentOwner.email,
assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL(),
cancellationReason: cancellationReason || 'The document has been cancelled.',
});
const branding = document.team?.teamGlobalSettings
? teamGlobalSettingsToBranding(document.team.teamGlobalSettings)
: undefined;
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang: documentMeta?.language, branding }),
renderEmailWithI18N(template, {
lang: documentMeta?.language,
branding,
plainText: true,
}),
]);
await mailer.sendMail({
to: {
name: recipient.name,
address: recipient.email,
},
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
subject: i18n._(msg`Document "${document.title}" Cancelled`),
html,
text,
});
}),
);
});
};

View File

@ -0,0 +1,33 @@
import { z } from 'zod';
import { type JobDefinition } from '../../client/_internal/job';
const SEND_DOCUMENT_CANCELLED_EMAILS_JOB_DEFINITION_ID = 'send.document.cancelled.emails';
const SEND_DOCUMENT_CANCELLED_EMAILS_JOB_DEFINITION_SCHEMA = z.object({
documentId: z.number(),
cancellationReason: z.string().optional(),
requestMetadata: z.any().optional(),
});
export type TSendDocumentCancelledEmailsJobDefinition = z.infer<
typeof SEND_DOCUMENT_CANCELLED_EMAILS_JOB_DEFINITION_SCHEMA
>;
export const SEND_DOCUMENT_CANCELLED_EMAILS_JOB_DEFINITION = {
id: SEND_DOCUMENT_CANCELLED_EMAILS_JOB_DEFINITION_ID,
name: 'Send Document Cancelled Emails',
version: '1.0.0',
trigger: {
name: SEND_DOCUMENT_CANCELLED_EMAILS_JOB_DEFINITION_ID,
schema: SEND_DOCUMENT_CANCELLED_EMAILS_JOB_DEFINITION_SCHEMA,
},
handler: async ({ payload, io }) => {
const handler = await import('./send-document-cancelled-emails.handler');
await handler.run({ payload, io });
},
} as const satisfies JobDefinition<
typeof SEND_DOCUMENT_CANCELLED_EMAILS_JOB_DEFINITION_ID,
TSendDocumentCancelledEmailsJobDefinition
>;

View File

@ -1,6 +1,7 @@
import { createElement } from 'react'; import { createElement } from 'react';
import { msg } from '@lingui/macro'; import { msg } from '@lingui/macro';
import type { TeamGlobalSettings } from '@prisma/client';
import { parse } from 'csv-parse/sync'; import { parse } from 'csv-parse/sync';
import { z } from 'zod'; import { z } from 'zod';
@ -10,7 +11,6 @@ import { sendDocument } from '@documenso/lib/server-only/document/send-document'
import { createDocumentFromTemplate } from '@documenso/lib/server-only/template/create-document-from-template'; import { createDocumentFromTemplate } from '@documenso/lib/server-only/template/create-document-from-template';
import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id'; import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import type { TeamGlobalSettings } from '@documenso/prisma/client';
import { getI18nInstance } from '../../../client-only/providers/i18n-server'; import { getI18nInstance } from '../../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app'; import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';

View File

@ -6,9 +6,11 @@ import { PDFDocument } from 'pdf-lib';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import { signPdf } from '@documenso/signing'; import { signPdf } from '@documenso/signing';
import { AppError, AppErrorCode } from '../../../errors/app-error';
import { sendCompletedEmail } from '../../../server-only/document/send-completed-email'; import { sendCompletedEmail } from '../../../server-only/document/send-completed-email';
import PostHogServerClient from '../../../server-only/feature-flags/get-post-hog-server-client'; import PostHogServerClient from '../../../server-only/feature-flags/get-post-hog-server-client';
import { getCertificatePdf } from '../../../server-only/htmltopdf/get-certificate-pdf'; import { getCertificatePdf } from '../../../server-only/htmltopdf/get-certificate-pdf';
import { addRejectionStampToPdf } from '../../../server-only/pdf/add-rejection-stamp-to-pdf';
import { flattenAnnotations } from '../../../server-only/pdf/flatten-annotations'; import { flattenAnnotations } from '../../../server-only/pdf/flatten-annotations';
import { flattenForm } from '../../../server-only/pdf/flatten-form'; import { flattenForm } from '../../../server-only/pdf/flatten-form';
import { insertFieldInPDF } from '../../../server-only/pdf/insert-field-in-pdf'; import { insertFieldInPDF } from '../../../server-only/pdf/insert-field-in-pdf';
@ -22,6 +24,7 @@ import {
import { getFileServerSide } from '../../../universal/upload/get-file.server'; import { getFileServerSide } from '../../../universal/upload/get-file.server';
import { putPdfFileServerSide } from '../../../universal/upload/put-file.server'; import { putPdfFileServerSide } from '../../../universal/upload/put-file.server';
import { fieldsContainUnsignedRequiredField } from '../../../utils/advanced-fields-helpers'; import { fieldsContainUnsignedRequiredField } from '../../../utils/advanced-fields-helpers';
import { isDocumentCompleted } from '../../../utils/document';
import { createDocumentAuditLogData } from '../../../utils/document-audit-logs'; import { createDocumentAuditLogData } from '../../../utils/document-audit-logs';
import type { JobRunIO } from '../../client/_internal/job'; import type { JobRunIO } from '../../client/_internal/job';
import type { TSealDocumentJobDefinition } from './seal-document'; import type { TSealDocumentJobDefinition } from './seal-document';
@ -38,11 +41,6 @@ export const run = async ({
const document = await prisma.document.findFirstOrThrow({ const document = await prisma.document.findFirstOrThrow({
where: { where: {
id: documentId, id: documentId,
recipients: {
every: {
signingStatus: SigningStatus.SIGNED,
},
},
}, },
include: { include: {
documentMeta: true, documentMeta: true,
@ -59,6 +57,16 @@ export const run = async ({
}, },
}); });
const isComplete =
document.recipients.some((recipient) => recipient.signingStatus === SigningStatus.REJECTED) ||
document.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 // 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. // after it has already run through the update task further below.
// eslint-disable-next-line @typescript-eslint/require-await // eslint-disable-next-line @typescript-eslint/require-await
@ -91,9 +99,15 @@ export const run = async ({
}, },
}); });
if (recipients.some((recipient) => recipient.signingStatus !== SigningStatus.SIGNED)) { // Determine if the document has been rejected by checking if any recipient has rejected it
throw new Error(`Document ${document.id} has unsigned recipients`); const rejectedRecipient = recipients.find(
} (recipient) => recipient.signingStatus === SigningStatus.REJECTED,
);
const isRejected = Boolean(rejectedRecipient);
// Get the rejection reason from the rejected recipient
const rejectionReason = rejectedRecipient?.rejectionReason ?? '';
const fields = await prisma.field.findMany({ const fields = await prisma.field.findMany({
where: { where: {
@ -104,7 +118,8 @@ export const run = async ({
}, },
}); });
if (fieldsContainUnsignedRequiredField(fields)) { // Skip the field check if the document is rejected
if (!isRejected && fieldsContainUnsignedRequiredField(fields)) {
throw new Error(`Document ${document.id} has unsigned required fields`); throw new Error(`Document ${document.id} has unsigned required fields`);
} }
@ -132,6 +147,11 @@ export const run = async ({
flattenForm(pdfDoc); flattenForm(pdfDoc);
flattenAnnotations(pdfDoc); flattenAnnotations(pdfDoc);
// Add rejection stamp if the document is rejected
if (isRejected && rejectionReason) {
await addRejectionStampToPdf(pdfDoc, rejectionReason);
}
if (certificateData) { if (certificateData) {
const certificateDoc = await PDFDocument.load(certificateData); const certificateDoc = await PDFDocument.load(certificateData);
@ -160,8 +180,11 @@ export const run = async ({
const { name } = path.parse(document.title); const { name } = path.parse(document.title);
// Add suffix based on document status
const suffix = isRejected ? '_rejected.pdf' : '_signed.pdf';
const documentData = await putPdfFileServerSide({ const documentData = await putPdfFileServerSide({
name: `${name}_signed.pdf`, name: `${name}${suffix}`,
type: 'application/pdf', type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(pdfBuffer), arrayBuffer: async () => Promise.resolve(pdfBuffer),
}); });
@ -177,6 +200,7 @@ export const run = async ({
event: 'App: Document Sealed', event: 'App: Document Sealed',
properties: { properties: {
documentId: document.id, documentId: document.id,
isRejected,
}, },
}); });
} }
@ -194,7 +218,7 @@ export const run = async ({
id: document.id, id: document.id,
}, },
data: { data: {
status: DocumentStatus.COMPLETED, status: isRejected ? DocumentStatus.REJECTED : DocumentStatus.COMPLETED,
completedAt: new Date(), completedAt: new Date(),
}, },
}); });
@ -216,6 +240,7 @@ export const run = async ({
user: null, user: null,
data: { data: {
transactionId: nanoid(), transactionId: nanoid(),
...(isRejected ? { isRejected: true, rejectionReason: rejectionReason } : {}),
}, },
}), }),
}); });
@ -223,9 +248,9 @@ export const run = async ({
}); });
await io.runTask('send-completed-email', async () => { await io.runTask('send-completed-email', async () => {
let shouldSendCompletedEmail = sendEmail && !isResealing; let shouldSendCompletedEmail = sendEmail && !isResealing && !isRejected;
if (isResealing && documentStatus !== DocumentStatus.COMPLETED) { if (isResealing && !isDocumentCompleted(document.status)) {
shouldSendCompletedEmail = sendEmail; shouldSendCompletedEmail = sendEmail;
} }
@ -246,7 +271,9 @@ export const run = async ({
}); });
await triggerWebhook({ await triggerWebhook({
event: WebhookTriggerEvents.DOCUMENT_COMPLETED, event: isRejected
? WebhookTriggerEvents.DOCUMENT_REJECTED
: WebhookTriggerEvents.DOCUMENT_COMPLETED,
data: ZWebhookDocumentSchema.parse(mapDocumentToWebhookDocumentPayload(updatedDocument)), data: ZWebhookDocumentSchema.parse(mapDocumentToWebhookDocumentPayload(updatedDocument)),
userId: updatedDocument.userId, userId: updatedDocument.userId,
teamId: updatedDocument.teamId ?? undefined, teamId: updatedDocument.teamId ?? undefined,

View File

@ -13,6 +13,7 @@ export const getDocumentStats = async () => {
[ExtendedDocumentStatus.DRAFT]: 0, [ExtendedDocumentStatus.DRAFT]: 0,
[ExtendedDocumentStatus.PENDING]: 0, [ExtendedDocumentStatus.PENDING]: 0,
[ExtendedDocumentStatus.COMPLETED]: 0, [ExtendedDocumentStatus.COMPLETED]: 0,
[ExtendedDocumentStatus.REJECTED]: 0,
[ExtendedDocumentStatus.ALL]: 0, [ExtendedDocumentStatus.ALL]: 0,
}; };

View File

@ -1,4 +1,4 @@
import { DocumentStatus } from '@prisma/client'; import { DocumentStatus, SubscriptionStatus } from '@prisma/client';
import { kyselyPrisma, sql } from '@documenso/prisma'; import { kyselyPrisma, sql } from '@documenso/prisma';
@ -44,7 +44,6 @@ export async function getSigningVolume({
.on('td.status', '=', sql.lit(DocumentStatus.COMPLETED)) .on('td.status', '=', sql.lit(DocumentStatus.COMPLETED))
.on('td.deletedAt', 'is', null), .on('td.deletedAt', 'is', null),
) )
// @ts-expect-error - Raw SQL enum casting not properly typed by Kysely
.where(sql`s.status = ${SubscriptionStatus.ACTIVE}::"SubscriptionStatus"`) .where(sql`s.status = ${SubscriptionStatus.ACTIVE}::"SubscriptionStatus"`)
.where((eb) => .where((eb) =>
eb.or([ eb.or([
@ -82,7 +81,6 @@ export async function getSigningVolume({
.selectFrom('Subscription as s') .selectFrom('Subscription as s')
.leftJoin('User as u', 's.userId', 'u.id') .leftJoin('User as u', 's.userId', 'u.id')
.leftJoin('Team as t', 's.teamId', 't.id') .leftJoin('Team as t', 's.teamId', 't.id')
// @ts-expect-error - Raw SQL enum casting not properly typed by Kysely
.where(sql`s.status = ${SubscriptionStatus.ACTIVE}::"SubscriptionStatus"`) .where(sql`s.status = ${SubscriptionStatus.ACTIVE}::"SubscriptionStatus"`)
.where((eb) => .where((eb) =>
eb.or([ eb.or([

View File

@ -26,6 +26,7 @@ import {
mapDocumentToWebhookDocumentPayload, mapDocumentToWebhookDocumentPayload,
} from '../../types/webhook-payload'; } from '../../types/webhook-payload';
import type { ApiRequestMetadata } from '../../universal/extract-request-metadata'; import type { ApiRequestMetadata } from '../../universal/extract-request-metadata';
import { isDocumentCompleted } from '../../utils/document';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs'; import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n'; import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { teamGlobalSettingsToBranding } from '../../utils/team-global-settings-to-branding'; import { teamGlobalSettingsToBranding } from '../../utils/team-global-settings-to-branding';
@ -161,7 +162,7 @@ const handleDocumentOwnerDelete = async ({
} }
// Soft delete completed documents. // Soft delete completed documents.
if (document.status === DocumentStatus.COMPLETED) { if (isDocumentCompleted(document.status)) {
return await prisma.$transaction(async (tx) => { return await prisma.$transaction(async (tx) => {
await tx.documentAuditLog.create({ await tx.documentAuditLog.create({
data: createDocumentAuditLogData({ data: createDocumentAuditLogData({

View File

@ -356,6 +356,24 @@ const findDocumentsFilter = (status: ExtendedDocumentStatus, user: User) => {
}, },
], ],
})) }))
.with(ExtendedDocumentStatus.REJECTED, () => ({
OR: [
{
userId: user.id,
teamId: null,
status: ExtendedDocumentStatus.REJECTED,
},
{
status: ExtendedDocumentStatus.REJECTED,
recipients: {
some: {
email: user.email,
signingStatus: SigningStatus.REJECTED,
},
},
},
],
}))
.exhaustive(); .exhaustive();
}; };
@ -548,5 +566,38 @@ const findTeamDocumentsFilter = (
return filter; return filter;
}) })
.with(ExtendedDocumentStatus.REJECTED, () => {
const filter: Prisma.DocumentWhereInput = {
status: ExtendedDocumentStatus.REJECTED,
OR: [
{
teamId: team.id,
OR: visibilityFilters,
},
],
};
if (teamEmail && filter.OR) {
filter.OR.push(
{
recipients: {
some: {
email: teamEmail,
signingStatus: SigningStatus.REJECTED,
},
},
OR: visibilityFilters,
},
{
user: {
email: teamEmail,
},
OR: visibilityFilters,
},
);
}
return filter;
})
.exhaustive(); .exhaustive();
}; };

View File

@ -16,6 +16,7 @@ export const getDocumentCertificateAuditLogs = async ({
type: { type: {
in: [ in: [
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED, DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED,
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_REJECTED,
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED, DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED,
DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT, DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
], ],
@ -29,6 +30,9 @@ export const getDocumentCertificateAuditLogs = async ({
[DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED]: auditLogs.filter( [DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED]: auditLogs.filter(
(log) => log.type === DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED, (log) => log.type === DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED,
), ),
[DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_REJECTED]: auditLogs.filter(
(log) => log.type === DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_REJECTED,
),
[DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED]: auditLogs.filter( [DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED]: auditLogs.filter(
(log) => log.type === DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED, (log) => log.type === DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED,
), ),

View File

@ -44,6 +44,7 @@ export const getStats = async ({ user, period, search = '', ...options }: GetSta
[ExtendedDocumentStatus.DRAFT]: 0, [ExtendedDocumentStatus.DRAFT]: 0,
[ExtendedDocumentStatus.PENDING]: 0, [ExtendedDocumentStatus.PENDING]: 0,
[ExtendedDocumentStatus.COMPLETED]: 0, [ExtendedDocumentStatus.COMPLETED]: 0,
[ExtendedDocumentStatus.REJECTED]: 0,
[ExtendedDocumentStatus.INBOX]: 0, [ExtendedDocumentStatus.INBOX]: 0,
[ExtendedDocumentStatus.ALL]: 0, [ExtendedDocumentStatus.ALL]: 0,
}; };
@ -64,6 +65,10 @@ export const getStats = async ({ user, period, search = '', ...options }: GetSta
if (stat.status === ExtendedDocumentStatus.PENDING) { if (stat.status === ExtendedDocumentStatus.PENDING) {
stats[ExtendedDocumentStatus.PENDING] += stat._count._all; stats[ExtendedDocumentStatus.PENDING] += stat._count._all;
} }
if (stat.status === ExtendedDocumentStatus.REJECTED) {
stats[ExtendedDocumentStatus.REJECTED] += stat._count._all;
}
}); });
Object.keys(stats).forEach((key) => { Object.keys(stats).forEach((key) => {

View File

@ -1,18 +1,12 @@
import { SigningStatus } from '@prisma/client'; import { SigningStatus } from '@prisma/client';
import { WebhookTriggerEvents } from '@prisma/client';
import { jobs } from '@documenso/lib/jobs/client'; import { jobs } from '@documenso/lib/jobs/client';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error'; import { AppError, AppErrorCode } from '../../errors/app-error';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs'; import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import {
ZWebhookDocumentSchema,
mapDocumentToWebhookDocumentPayload,
} from '../../types/webhook-payload';
import type { RequestMetadata } from '../../universal/extract-request-metadata'; import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs'; import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
export type RejectDocumentWithTokenOptions = { export type RejectDocumentWithTokenOptions = {
token: string; token: string;
@ -84,7 +78,16 @@ export async function rejectDocumentWithToken({
}), }),
]); ]);
// Send email notifications // Trigger the seal document job to process the document asynchronously
await jobs.triggerJob({
name: 'internal.seal-document',
payload: {
documentId,
requestMetadata,
},
});
// Send email notifications to the rejecting recipient
await jobs.triggerJob({ await jobs.triggerJob({
name: 'send.signing.rejected.emails', name: 'send.signing.rejected.emails',
payload: { payload: {
@ -93,27 +96,14 @@ export async function rejectDocumentWithToken({
}, },
}); });
// Get the updated document with all recipients // Send cancellation emails to other recipients
const updatedDocument = await prisma.document.findFirst({ await jobs.triggerJob({
where: { name: 'send.document.cancelled.emails',
id: document.id, payload: {
documentId,
cancellationReason: reason,
requestMetadata,
}, },
include: {
recipients: true,
documentMeta: true,
},
});
if (!updatedDocument) {
throw new Error('Document not found after update');
}
// Trigger webhook for document rejection
await triggerWebhook({
event: WebhookTriggerEvents.DOCUMENT_REJECTED,
data: ZWebhookDocumentSchema.parse(mapDocumentToWebhookDocumentPayload(updatedDocument)),
userId: document.userId,
teamId: document.teamId ?? undefined,
}); });
return updatedRecipient; return updatedRecipient;

View File

@ -20,6 +20,7 @@ import { prisma } from '@documenso/prisma';
import { getI18nInstance } from '../../client-only/providers/i18n-server'; import { getI18nInstance } from '../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app'; import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { extractDerivedDocumentEmailSettings } from '../../types/document-email'; import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
import { isDocumentCompleted } from '../../utils/document';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n'; import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { teamGlobalSettingsToBranding } from '../../utils/team-global-settings-to-branding'; import { teamGlobalSettingsToBranding } from '../../utils/team-global-settings-to-branding';
import { getDocumentWhereInput } from './get-document-by-id'; import { getDocumentWhereInput } from './get-document-by-id';
@ -88,7 +89,7 @@ export const resendDocument = async ({
throw new Error('Can not send draft document'); throw new Error('Can not send draft document');
} }
if (document.status === DocumentStatus.COMPLETED) { if (isDocumentCompleted(document.status)) {
throw new Error('Can not send completed document'); throw new Error('Can not send completed document');
} }

View File

@ -18,6 +18,7 @@ import { getFileServerSide } from '../../universal/upload/get-file.server';
import { putPdfFileServerSide } from '../../universal/upload/put-file.server'; import { putPdfFileServerSide } from '../../universal/upload/put-file.server';
import { fieldsContainUnsignedRequiredField } from '../../utils/advanced-fields-helpers'; import { fieldsContainUnsignedRequiredField } from '../../utils/advanced-fields-helpers';
import { getCertificatePdf } from '../htmltopdf/get-certificate-pdf'; import { getCertificatePdf } from '../htmltopdf/get-certificate-pdf';
import { addRejectionStampToPdf } from '../pdf/add-rejection-stamp-to-pdf';
import { flattenAnnotations } from '../pdf/flatten-annotations'; import { flattenAnnotations } from '../pdf/flatten-annotations';
import { flattenForm } from '../pdf/flatten-form'; import { flattenForm } from '../pdf/flatten-form';
import { insertFieldInPDF } from '../pdf/insert-field-in-pdf'; import { insertFieldInPDF } from '../pdf/insert-field-in-pdf';
@ -41,11 +42,6 @@ export const sealDocument = async ({
const document = await prisma.document.findFirstOrThrow({ const document = await prisma.document.findFirstOrThrow({
where: { where: {
id: documentId, id: documentId,
recipients: {
every: {
signingStatus: SigningStatus.SIGNED,
},
},
}, },
include: { include: {
documentData: true, documentData: true,
@ -78,7 +74,21 @@ export const sealDocument = async ({
}, },
}); });
if (recipients.some((recipient) => recipient.signingStatus !== SigningStatus.SIGNED)) { // 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 isRejected = Boolean(rejectedRecipient);
// Get the rejection reason from the rejected recipient
const rejectionReason = rejectedRecipient?.rejectionReason ?? '';
// If the document is not rejected, ensure all recipients have signed
if (
!isRejected &&
recipients.some((recipient) => recipient.signingStatus !== SigningStatus.SIGNED)
) {
throw new Error(`Document ${document.id} has unsigned recipients`); throw new Error(`Document ${document.id} has unsigned recipients`);
} }
@ -91,7 +101,8 @@ export const sealDocument = async ({
}, },
}); });
if (fieldsContainUnsignedRequiredField(fields)) { // Skip the field check if the document is rejected
if (!isRejected && fieldsContainUnsignedRequiredField(fields)) {
throw new Error(`Document ${document.id} has unsigned required fields`); throw new Error(`Document ${document.id} has unsigned required fields`);
} }
@ -119,6 +130,11 @@ export const sealDocument = async ({
flattenForm(doc); flattenForm(doc);
flattenAnnotations(doc); flattenAnnotations(doc);
// Add rejection stamp if the document is rejected
if (isRejected && rejectionReason) {
await addRejectionStampToPdf(doc, rejectionReason);
}
if (certificateData) { if (certificateData) {
const certificate = await PDFDocument.load(certificateData); const certificate = await PDFDocument.load(certificateData);
@ -142,8 +158,11 @@ export const sealDocument = async ({
const { name } = path.parse(document.title); const { name } = path.parse(document.title);
// Add suffix based on document status
const suffix = isRejected ? '_rejected.pdf' : '_signed.pdf';
const { data: newData } = await putPdfFileServerSide({ const { data: newData } = await putPdfFileServerSide({
name: `${name}_signed.pdf`, name: `${name}${suffix}`,
type: 'application/pdf', type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(pdfBuffer), arrayBuffer: async () => Promise.resolve(pdfBuffer),
}); });
@ -156,6 +175,7 @@ export const sealDocument = async ({
event: 'App: Document Sealed', event: 'App: Document Sealed',
properties: { properties: {
documentId: document.id, documentId: document.id,
isRejected,
}, },
}); });
} }
@ -166,7 +186,7 @@ export const sealDocument = async ({
id: document.id, id: document.id,
}, },
data: { data: {
status: DocumentStatus.COMPLETED, status: isRejected ? DocumentStatus.REJECTED : DocumentStatus.COMPLETED,
completedAt: new Date(), completedAt: new Date(),
}, },
}); });
@ -188,6 +208,7 @@ export const sealDocument = async ({
user: null, user: null,
data: { data: {
transactionId: nanoid(), transactionId: nanoid(),
...(isRejected ? { isRejected: true, rejectionReason } : {}),
}, },
}), }),
}); });
@ -209,7 +230,9 @@ export const sealDocument = async ({
}); });
await triggerWebhook({ await triggerWebhook({
event: WebhookTriggerEvents.DOCUMENT_COMPLETED, event: isRejected
? WebhookTriggerEvents.DOCUMENT_REJECTED
: WebhookTriggerEvents.DOCUMENT_COMPLETED,
data: ZWebhookDocumentSchema.parse(mapDocumentToWebhookDocumentPayload(updatedDocument)), data: ZWebhookDocumentSchema.parse(mapDocumentToWebhookDocumentPayload(updatedDocument)),
userId: document.userId, userId: document.userId,
teamId: document.teamId ?? undefined, teamId: document.teamId ?? undefined,

View File

@ -20,6 +20,7 @@ import {
} from '../../types/webhook-payload'; } from '../../types/webhook-payload';
import { getFileServerSide } from '../../universal/upload/get-file.server'; import { getFileServerSide } from '../../universal/upload/get-file.server';
import { putPdfFileServerSide } from '../../universal/upload/put-file.server'; import { putPdfFileServerSide } from '../../universal/upload/put-file.server';
import { isDocumentCompleted } from '../../utils/document';
import { insertFormValuesInPdf } from '../pdf/insert-form-values-in-pdf'; import { insertFormValuesInPdf } from '../pdf/insert-form-values-in-pdf';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook'; import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
@ -74,7 +75,7 @@ export const sendDocument = async ({
throw new Error('Document has no recipients'); throw new Error('Document has no recipients');
} }
if (document.status === DocumentStatus.COMPLETED) { if (isDocumentCompleted(document.status)) {
throw new Error('Can not send completed document'); throw new Error('Can not send completed document');
} }

View File

@ -1,5 +1,6 @@
import { FieldType, RecipientRole, SigningStatus } from '@prisma/client';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import { FieldType, RecipientRole, SigningStatus } from '@documenso/prisma/client';
export type GetFieldsForTokenOptions = { export type GetFieldsForTokenOptions = {
token: string; token: string;

View File

@ -0,0 +1,87 @@
import fontkit from '@pdf-lib/fontkit';
import type { PDFDocument } from 'pdf-lib';
import { TextAlignment, rgb, setFontAndSize } from 'pdf-lib';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
/**
* Adds a rejection stamp to each page of a PDF document.
* The stamp is placed in the center of the page.
*/
export async function addRejectionStampToPdf(
pdfDoc: PDFDocument,
reason: string,
): Promise<PDFDocument> {
const pages = pdfDoc.getPages();
pdfDoc.registerFontkit(fontkit);
const fontBytes = await fetch(`${NEXT_PUBLIC_WEBAPP_URL()}/fonts/noto-sans.ttf`).then(
async (res) => res.arrayBuffer(),
);
const font = await pdfDoc.embedFont(fontBytes, {
customName: 'Noto',
});
const form = pdfDoc.getForm();
for (let i = 0; i < pages.length; i++) {
const page = pages[i];
const { width, height } = page.getSize();
// Draw the "REJECTED" text
const rejectedTitleText = 'DOCUMENT REJECTED';
const rejectedTitleFontSize = 36;
const rejectedTitleTextField = form.createTextField(`internal-document-rejected-title-${i}`);
if (!rejectedTitleTextField.acroField.getDefaultAppearance()) {
rejectedTitleTextField.acroField.setDefaultAppearance(
setFontAndSize('Noto', rejectedTitleFontSize).toString(),
);
}
rejectedTitleTextField.updateAppearances(font);
rejectedTitleTextField.setFontSize(rejectedTitleFontSize);
rejectedTitleTextField.setText(rejectedTitleText);
rejectedTitleTextField.setAlignment(TextAlignment.Center);
const rejectedTitleTextWidth =
font.widthOfTextAtSize(rejectedTitleText, rejectedTitleFontSize) * 1.2;
const rejectedTitleTextHeight = font.heightAtSize(rejectedTitleFontSize);
// Calculate the center position of the page
const centerX = width / 2;
const centerY = height / 2;
// Position the title text at the center of the page
const rejectedTitleTextX = centerX - rejectedTitleTextWidth / 2;
const rejectedTitleTextY = centerY - rejectedTitleTextHeight / 2;
// Add padding for the rectangle
const padding = 20;
// Draw the stamp background
page.drawRectangle({
x: rejectedTitleTextX - padding / 2,
y: rejectedTitleTextY - padding / 2,
width: rejectedTitleTextWidth + padding,
height: rejectedTitleTextHeight + padding,
borderColor: rgb(220 / 255, 38 / 255, 38 / 255),
borderWidth: 4,
});
rejectedTitleTextField.addToPage(page, {
x: rejectedTitleTextX,
y: rejectedTitleTextY,
width: rejectedTitleTextWidth,
height: rejectedTitleTextHeight,
textColor: rgb(220 / 255, 38 / 255, 38 / 255),
backgroundColor: undefined,
borderWidth: 0,
borderColor: undefined,
});
}
return pdfDoc;
}

View File

@ -1,5 +1,6 @@
import { TeamMemberRole } from '@prisma/client';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import { TeamMemberRole } from '@documenso/prisma/client';
export type GetApiTokensOptions = { export type GetApiTokensOptions = {
userId: number; userId: number;

View File

@ -1,5 +1,6 @@
import { FieldType } from '@prisma/client';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import { FieldType } from '@documenso/prisma/client';
import { AppError, AppErrorCode } from '../../errors/app-error'; import { AppError, AppErrorCode } from '../../errors/app-error';

View File

@ -65,9 +65,7 @@ export const createTeamMemberInvites = async ({
}); });
const teamMemberEmails = team.members.map((member) => member.user.email); const teamMemberEmails = team.members.map((member) => member.user.email);
const teamMemberInviteEmails = team.invites const teamMemberInviteEmails = team.invites.map((invite) => invite.email);
.filter((invite) => invite.status === TeamMemberInviteStatus.PENDING)
.map((invite) => invite.email);
const currentTeamMember = team.members.find((member) => member.user.id === userId); const currentTeamMember = team.members.find((member) => member.user.id === userId);
if (!currentTeamMember) { if (!currentTeamMember) {

View File

@ -1,5 +1,5 @@
import type { TeamMemberInvite } from '@prisma/client'; import type { TeamMemberInvite } from '@prisma/client';
import { Prisma, TeamMemberInviteStatus } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { P, match } from 'ts-pattern'; import { P, match } from 'ts-pattern';
import type { z } from 'zod'; import type { z } from 'zod';
@ -71,7 +71,6 @@ export const findTeamMemberInvites = async ({
const whereClause: Prisma.TeamMemberInviteWhereInput = { const whereClause: Prisma.TeamMemberInviteWhereInput = {
...termFilters, ...termFilters,
teamId: userTeam.id, teamId: userTeam.id,
status: TeamMemberInviteStatus.PENDING,
}; };
const [data, count] = await Promise.all([ const [data, count] = await Promise.all([

View File

@ -1,4 +1,3 @@
import { TeamMemberInviteStatus } from '@prisma/client';
import type { z } from 'zod'; import type { z } from 'zod';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
@ -26,7 +25,6 @@ export const getTeamInvitations = async ({
return await prisma.teamMemberInvite.findMany({ return await prisma.teamMemberInvite.findMany({
where: { where: {
email, email,
status: TeamMemberInviteStatus.PENDING,
}, },
include: { include: {
team: { team: {

View File

@ -37,6 +37,7 @@ import {
mapDocumentToWebhookDocumentPayload, mapDocumentToWebhookDocumentPayload,
} from '../../types/webhook-payload'; } from '../../types/webhook-payload';
import type { ApiRequestMetadata } from '../../universal/extract-request-metadata'; import type { ApiRequestMetadata } from '../../universal/extract-request-metadata';
import { isRequiredField } from '../../utils/advanced-fields-helpers';
import type { CreateDocumentAuditLogDataResponse } from '../../utils/document-audit-logs'; import type { CreateDocumentAuditLogDataResponse } from '../../utils/document-audit-logs';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs'; import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
import { import {
@ -176,20 +177,28 @@ export const createDocumentFromDirectTemplate = async ({
const metaSigningOrder = template.templateMeta?.signingOrder || DocumentSigningOrder.PARALLEL; const metaSigningOrder = template.templateMeta?.signingOrder || DocumentSigningOrder.PARALLEL;
// Associate, validate and map to a query every direct template recipient field with the provided fields. // Associate, validate and map to a query every direct template recipient field with the provided fields.
// Only process fields that are either required or have been signed by the user
const fieldsToProcess = directTemplateRecipient.fields.filter((templateField) => {
const signedFieldValue = signedFieldValues.find((value) => value.fieldId === templateField.id);
// Include if it's required or has a signed value
return isRequiredField(templateField) || signedFieldValue !== undefined;
});
const createDirectRecipientFieldArgs = await Promise.all( const createDirectRecipientFieldArgs = await Promise.all(
directTemplateRecipient.fields.map(async (templateField) => { fieldsToProcess.map(async (templateField) => {
const signedFieldValue = signedFieldValues.find( const signedFieldValue = signedFieldValues.find(
(value) => value.fieldId === templateField.id, (value) => value.fieldId === templateField.id,
); );
if (!signedFieldValue) { if (isRequiredField(templateField) && !signedFieldValue) {
throw new AppError(AppErrorCode.INVALID_BODY, { throw new AppError(AppErrorCode.INVALID_BODY, {
message: 'Invalid, missing or changed fields', message: 'Invalid, missing or changed fields',
}); });
} }
if (templateField.type === FieldType.NAME && directRecipientName === undefined) { if (templateField.type === FieldType.NAME && directRecipientName === undefined) {
directRecipientName === signedFieldValue.value; directRecipientName === signedFieldValue?.value;
} }
const derivedRecipientActionAuth = await validateFieldAuth({ const derivedRecipientActionAuth = await validateFieldAuth({
@ -200,9 +209,18 @@ export const createDocumentFromDirectTemplate = async ({
}, },
field: templateField, field: templateField,
userId: user?.id, userId: user?.id,
authOptions: signedFieldValue.authOptions, authOptions: signedFieldValue?.authOptions,
}); });
if (!signedFieldValue) {
return {
templateField,
customText: '',
derivedRecipientActionAuth,
signature: null,
};
}
const { value, isBase64 } = signedFieldValue; const { value, isBase64 } = signedFieldValue;
const isSignatureField = const isSignatureField =
@ -380,7 +398,7 @@ export const createDocumentFromDirectTemplate = async ({
positionY: templateField.positionY, positionY: templateField.positionY,
width: templateField.width, width: templateField.width,
height: templateField.height, height: templateField.height,
customText, customText: customText ?? '',
inserted: true, inserted: true,
fieldMeta: templateField.fieldMeta || Prisma.JsonNull, fieldMeta: templateField.fieldMeta || Prisma.JsonNull,
})), })),

View File

@ -9,6 +9,7 @@ import {
SigningStatus, SigningStatus,
WebhookTriggerEvents, WebhookTriggerEvents,
} from '@prisma/client'; } from '@prisma/client';
import { match } from 'ts-pattern';
import { nanoid } from '@documenso/lib/universal/id'; import { nanoid } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
@ -18,7 +19,20 @@ import { AppError, AppErrorCode } from '../../errors/app-error';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs'; import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import { ZRecipientAuthOptionsSchema } from '../../types/document-auth'; import { ZRecipientAuthOptionsSchema } from '../../types/document-auth';
import type { TDocumentEmailSettings } from '../../types/document-email'; import type { TDocumentEmailSettings } from '../../types/document-email';
import { ZFieldMetaSchema } from '../../types/field-meta'; import type {
TCheckboxFieldMeta,
TDropdownFieldMeta,
TFieldMetaPrefillFieldsSchema,
TNumberFieldMeta,
TRadioFieldMeta,
TTextFieldMeta,
} from '../../types/field-meta';
import {
ZCheckboxFieldMeta,
ZDropdownFieldMeta,
ZFieldMetaSchema,
ZRadioFieldMeta,
} from '../../types/field-meta';
import { import {
ZWebhookDocumentSchema, ZWebhookDocumentSchema,
mapDocumentToWebhookDocumentPayload, mapDocumentToWebhookDocumentPayload,
@ -51,6 +65,7 @@ export type CreateDocumentFromTemplateOptions = {
email: string; email: string;
signingOrder?: number | null; signingOrder?: number | null;
}[]; }[];
prefillFields?: TFieldMetaPrefillFieldsSchema[];
customDocumentDataId?: string; customDocumentDataId?: string;
/** /**
@ -73,6 +88,175 @@ export type CreateDocumentFromTemplateOptions = {
requestMetadata: ApiRequestMetadata; requestMetadata: ApiRequestMetadata;
}; };
const getUpdatedFieldMeta = (field: Field, prefillField?: TFieldMetaPrefillFieldsSchema) => {
if (!prefillField) {
return field.fieldMeta;
}
const advancedField = ['NUMBER', 'RADIO', 'CHECKBOX', 'DROPDOWN', 'TEXT'].includes(field.type);
if (!advancedField) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: `Field ${field.id} is not an advanced field and cannot have field meta information. Allowed types: NUMBER, RADIO, CHECKBOX, DROPDOWN, TEXT.`,
});
}
// We've already validated that the field types match at a higher level
// Start with the existing field meta or an empty object
const existingMeta = field.fieldMeta || {};
// Apply type-specific updates based on the prefill field type using ts-pattern
return match(prefillField)
.with({ type: 'text' }, (field) => {
if (typeof field.value !== 'string') {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: `Invalid value for TEXT field ${field.id}: expected string, got ${typeof field.value}`,
});
}
const meta: TTextFieldMeta = {
...existingMeta,
type: 'text',
label: field.label,
placeholder: field.placeholder,
text: field.value,
};
return meta;
})
.with({ type: 'number' }, (field) => {
if (typeof field.value !== 'string') {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: `Invalid value for NUMBER field ${field.id}: expected string, got ${typeof field.value}`,
});
}
const meta: TNumberFieldMeta = {
...existingMeta,
type: 'number',
label: field.label,
placeholder: field.placeholder,
value: field.value,
};
return meta;
})
.with({ type: 'radio' }, (field) => {
if (typeof field.value !== 'string') {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: `Invalid value for RADIO field ${field.id}: expected string, got ${typeof field.value}`,
});
}
const result = ZRadioFieldMeta.safeParse(existingMeta);
if (!result.success) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: `Invalid field meta for RADIO field ${field.id}`,
});
}
const radioMeta = result.data;
// Validate that the value exists in the options
const valueExists = radioMeta.values?.some((option) => option.value === field.value);
if (!valueExists) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: `Value "${field.value}" not found in options for RADIO field ${field.id}`,
});
}
const newValues = radioMeta.values?.map((option) => ({
...option,
checked: option.value === field.value,
}));
const meta: TRadioFieldMeta = {
...existingMeta,
type: 'radio',
label: field.label,
values: newValues,
};
return meta;
})
.with({ type: 'checkbox' }, (field) => {
const result = ZCheckboxFieldMeta.safeParse(existingMeta);
if (!result.success) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: `Invalid field meta for CHECKBOX field ${field.id}`,
});
}
const checkboxMeta = result.data;
if (!field.value) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: `Value is required for CHECKBOX field ${field.id}`,
});
}
const fieldValue = field.value;
// Validate that all values exist in the options
for (const value of fieldValue) {
const valueExists = checkboxMeta.values?.some((option) => option.value === value);
if (!valueExists) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: `Value "${value}" not found in options for CHECKBOX field ${field.id}`,
});
}
}
const newValues = checkboxMeta.values?.map((option) => ({
...option,
checked: fieldValue.includes(option.value),
}));
const meta: TCheckboxFieldMeta = {
...existingMeta,
type: 'checkbox',
label: field.label,
values: newValues,
};
return meta;
})
.with({ type: 'dropdown' }, (field) => {
const result = ZDropdownFieldMeta.safeParse(existingMeta);
if (!result.success) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: `Invalid field meta for DROPDOWN field ${field.id}`,
});
}
const dropdownMeta = result.data;
// Validate that the value exists in the options if values are defined
const valueExists = dropdownMeta.values?.some((option) => option.value === field.value);
if (!valueExists) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: `Value "${field.value}" not found in options for DROPDOWN field ${field.id}`,
});
}
const meta: TDropdownFieldMeta = {
...existingMeta,
type: 'dropdown',
label: field.label,
defaultValue: field.value,
};
return meta;
})
.otherwise(() => field.fieldMeta);
};
export const createDocumentFromTemplate = async ({ export const createDocumentFromTemplate = async ({
templateId, templateId,
externalId, externalId,
@ -82,6 +266,7 @@ export const createDocumentFromTemplate = async ({
customDocumentDataId, customDocumentDataId,
override, override,
requestMetadata, requestMetadata,
prefillFields,
}: CreateDocumentFromTemplateOptions) => { }: CreateDocumentFromTemplateOptions) => {
const template = await prisma.template.findUnique({ const template = await prisma.template.findUnique({
where: { where: {
@ -259,6 +444,47 @@ export const createDocumentFromTemplate = async ({
let fieldsToCreate: Omit<Field, 'id' | 'secondaryId' | 'templateId'>[] = []; let fieldsToCreate: Omit<Field, 'id' | 'secondaryId' | 'templateId'>[] = [];
// Get all template field IDs first so we can validate later
const allTemplateFieldIds = finalRecipients.flatMap((recipient) =>
recipient.fields.map((field) => field.id),
);
if (prefillFields?.length) {
// Validate that all prefill field IDs exist in the template
const invalidFieldIds = prefillFields
.map((prefillField) => prefillField.id)
.filter((id) => !allTemplateFieldIds.includes(id));
if (invalidFieldIds.length > 0) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: `The following field IDs do not exist in the template: ${invalidFieldIds.join(', ')}`,
});
}
// Validate that all prefill fields have the correct type
for (const prefillField of prefillFields) {
const templateField = finalRecipients
.flatMap((recipient) => recipient.fields)
.find((field) => field.id === prefillField.id);
if (!templateField) {
// This should never happen due to the previous validation, but just in case
throw new AppError(AppErrorCode.INVALID_BODY, {
message: `Field with ID ${prefillField.id} not found in the template`,
});
}
const expectedType = templateField.type.toLowerCase();
const actualType = prefillField.type;
if (expectedType !== actualType) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: `Field type mismatch for field ${prefillField.id}: expected ${expectedType}, got ${actualType}`,
});
}
}
}
Object.values(finalRecipients).forEach(({ email, fields }) => { Object.values(finalRecipients).forEach(({ email, fields }) => {
const recipient = document.recipients.find((recipient) => recipient.email === email); const recipient = document.recipients.find((recipient) => recipient.email === email);
@ -267,19 +493,25 @@ export const createDocumentFromTemplate = async ({
} }
fieldsToCreate = fieldsToCreate.concat( fieldsToCreate = fieldsToCreate.concat(
fields.map((field) => ({ fields.map((field) => {
documentId: document.id, const prefillField = prefillFields?.find((value) => value.id === field.id);
recipientId: recipient.id, // Use type assertion to help TypeScript understand the structure
type: field.type, const updatedFieldMeta = getUpdatedFieldMeta(field, prefillField);
page: field.page,
positionX: field.positionX, return {
positionY: field.positionY, documentId: document.id,
width: field.width, recipientId: recipient.id,
height: field.height, type: field.type,
customText: '', page: field.page,
inserted: false, positionX: field.positionX,
fieldMeta: field.fieldMeta, positionY: field.positionY,
})), width: field.width,
height: field.height,
customText: '',
inserted: false,
fieldMeta: updatedFieldMeta,
};
}),
); );
}); });

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