mirror of
https://github.com/documenso/documenso.git
synced 2025-11-23 05:01:54 +10:00
Compare commits
7 Commits
exp/bg
...
feat/audit
| Author | SHA1 | Date | |
|---|---|---|---|
| 607f22513a | |||
| 99b0ad574e | |||
| 9594e1fee8 | |||
| 5e3a2b8f76 | |||
| f928503a33 | |||
| c389670785 | |||
| 99ad2eb645 |
@ -173,23 +173,13 @@ export const ConfigureFieldsView = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const onFieldCopy = useCallback(
|
const onFieldCopy = useCallback(
|
||||||
(event?: KeyboardEvent | null, options?: { duplicate?: boolean }) => {
|
(event?: KeyboardEvent | null, options?: { duplicate?: boolean; duplicateAll?: boolean }) => {
|
||||||
const { duplicate = false } = options ?? {};
|
const { duplicate = false, duplicateAll = false } = options ?? {};
|
||||||
|
|
||||||
if (lastActiveField) {
|
if (lastActiveField) {
|
||||||
event?.preventDefault();
|
event?.preventDefault();
|
||||||
|
|
||||||
if (!duplicate) {
|
if (duplicate) {
|
||||||
setFieldClipboard(lastActiveField);
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: 'Copied field',
|
|
||||||
description: 'Copied field to clipboard',
|
|
||||||
});
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const newField: TConfigureFieldsFormSchema['fields'][0] = {
|
const newField: TConfigureFieldsFormSchema['fields'][0] = {
|
||||||
...structuredClone(lastActiveField),
|
...structuredClone(lastActiveField),
|
||||||
nativeId: undefined,
|
nativeId: undefined,
|
||||||
@ -201,6 +191,41 @@ export const ConfigureFieldsView = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
append(newField);
|
append(newField);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (duplicateAll) {
|
||||||
|
const pages = Array.from(document.querySelectorAll(PDF_VIEWER_PAGE_SELECTOR));
|
||||||
|
|
||||||
|
pages.forEach((_, index) => {
|
||||||
|
const pageNumber = index + 1;
|
||||||
|
|
||||||
|
if (pageNumber === lastActiveField.pageNumber) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newField: TConfigureFieldsFormSchema['fields'][0] = {
|
||||||
|
...structuredClone(lastActiveField),
|
||||||
|
nativeId: undefined,
|
||||||
|
formId: nanoid(12),
|
||||||
|
signerEmail: selectedRecipient?.email ?? lastActiveField.signerEmail,
|
||||||
|
recipientId: selectedRecipient?.id ?? lastActiveField.recipientId,
|
||||||
|
pageNumber,
|
||||||
|
};
|
||||||
|
|
||||||
|
append(newField);
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setFieldClipboard(lastActiveField);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Copied field',
|
||||||
|
description: 'Copied field to clipboard',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[append, lastActiveField, selectedRecipient?.email, selectedRecipient?.id, toast],
|
[append, lastActiveField, selectedRecipient?.email, selectedRecipient?.id, toast],
|
||||||
@ -533,6 +558,7 @@ export const ConfigureFieldsView = ({
|
|||||||
onMove={(node) => onFieldMove(node, index)}
|
onMove={(node) => onFieldMove(node, index)}
|
||||||
onRemove={() => remove(index)}
|
onRemove={() => remove(index)}
|
||||||
onDuplicate={() => onFieldCopy(null, { duplicate: true })}
|
onDuplicate={() => onFieldCopy(null, { duplicate: true })}
|
||||||
|
onDuplicateAllPages={() => onFieldCopy(null, { duplicateAll: true })}
|
||||||
onFocus={() => setLastActiveField(field)}
|
onFocus={() => setLastActiveField(field)}
|
||||||
onBlur={() => setLastActiveField(null)}
|
onBlur={() => setLastActiveField(null)}
|
||||||
onAdvancedSettings={() => {
|
onAdvancedSettings={() => {
|
||||||
|
|||||||
@ -44,6 +44,7 @@ const ZTeamDocumentPreferencesFormSchema = z.object({
|
|||||||
documentLanguage: z.enum(SUPPORTED_LANGUAGE_CODES),
|
documentLanguage: z.enum(SUPPORTED_LANGUAGE_CODES),
|
||||||
includeSenderDetails: z.boolean(),
|
includeSenderDetails: z.boolean(),
|
||||||
includeSigningCertificate: z.boolean(),
|
includeSigningCertificate: z.boolean(),
|
||||||
|
includeAuditLog: z.boolean(),
|
||||||
signatureTypes: z.array(z.nativeEnum(DocumentSignatureType)).min(1, {
|
signatureTypes: z.array(z.nativeEnum(DocumentSignatureType)).min(1, {
|
||||||
message: msg`At least one signature type must be enabled`.id,
|
message: msg`At least one signature type must be enabled`.id,
|
||||||
}),
|
}),
|
||||||
@ -77,6 +78,7 @@ export const TeamDocumentPreferencesForm = ({
|
|||||||
: 'en',
|
: 'en',
|
||||||
includeSenderDetails: settings?.includeSenderDetails ?? false,
|
includeSenderDetails: settings?.includeSenderDetails ?? false,
|
||||||
includeSigningCertificate: settings?.includeSigningCertificate ?? true,
|
includeSigningCertificate: settings?.includeSigningCertificate ?? true,
|
||||||
|
includeAuditLog: settings?.includeAuditLog ?? false,
|
||||||
signatureTypes: extractTeamSignatureSettings(settings),
|
signatureTypes: extractTeamSignatureSettings(settings),
|
||||||
},
|
},
|
||||||
resolver: zodResolver(ZTeamDocumentPreferencesFormSchema),
|
resolver: zodResolver(ZTeamDocumentPreferencesFormSchema),
|
||||||
@ -91,6 +93,7 @@ export const TeamDocumentPreferencesForm = ({
|
|||||||
documentLanguage,
|
documentLanguage,
|
||||||
includeSenderDetails,
|
includeSenderDetails,
|
||||||
includeSigningCertificate,
|
includeSigningCertificate,
|
||||||
|
includeAuditLog,
|
||||||
signatureTypes,
|
signatureTypes,
|
||||||
} = data;
|
} = data;
|
||||||
|
|
||||||
@ -101,6 +104,7 @@ export const TeamDocumentPreferencesForm = ({
|
|||||||
documentLanguage,
|
documentLanguage,
|
||||||
includeSenderDetails,
|
includeSenderDetails,
|
||||||
includeSigningCertificate,
|
includeSigningCertificate,
|
||||||
|
includeAuditLog,
|
||||||
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
|
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
|
||||||
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
|
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
|
||||||
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
|
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
|
||||||
@ -307,7 +311,7 @@ export const TeamDocumentPreferencesForm = ({
|
|||||||
|
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
<Trans>
|
<Trans>
|
||||||
Controls whether the signing certificate will be included in the document when
|
Controls whether the signing certificate will be included with the document when
|
||||||
it is downloaded. The signing certificate can still be downloaded from the logs
|
it is downloaded. The signing certificate can still be downloaded from the logs
|
||||||
page separately.
|
page separately.
|
||||||
</Trans>
|
</Trans>
|
||||||
@ -316,6 +320,36 @@ export const TeamDocumentPreferencesForm = ({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="includeAuditLog"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex-1">
|
||||||
|
<FormLabel>
|
||||||
|
<Trans>Include the Audit Log in the Document</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<FormControl className="block">
|
||||||
|
<Switch
|
||||||
|
ref={field.ref}
|
||||||
|
name={field.name}
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormDescription>
|
||||||
|
<Trans>
|
||||||
|
Controls whether the audit log will be included with the document when it is
|
||||||
|
downloaded. The audit log can still be downloaded from the logs page separately.
|
||||||
|
</Trans>
|
||||||
|
</FormDescription>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="flex flex-row justify-end space-x-4">
|
<div className="flex flex-row justify-end space-x-4">
|
||||||
<Button type="submit" loading={form.formState.isSubmitting}>
|
<Button type="submit" loading={form.formState.isSubmitting}>
|
||||||
<Trans>Update</Trans>
|
<Trans>Update</Trans>
|
||||||
|
|||||||
@ -12,7 +12,14 @@ import { useSession } from '@documenso/lib/client-only/providers/session';
|
|||||||
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
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 { trpc } from '@documenso/trpc/react';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import {
|
||||||
|
SplitButton,
|
||||||
|
SplitButtonAction,
|
||||||
|
SplitButtonDropdown,
|
||||||
|
SplitButtonDropdownItem,
|
||||||
|
} from '@documenso/ui/primitives/split-button';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
export type DocumentPageViewButtonProps = {
|
export type DocumentPageViewButtonProps = {
|
||||||
@ -42,6 +49,12 @@ export const DocumentPageViewButton = ({ document }: DocumentPageViewButtonProps
|
|||||||
? `${documentsPath}/f/${document.folderId}/${document.id}/edit`
|
? `${documentsPath}/f/${document.folderId}/${document.id}/edit`
|
||||||
: `${documentsPath}/${document.id}/edit`;
|
: `${documentsPath}/${document.id}/edit`;
|
||||||
|
|
||||||
|
const { mutateAsync: downloadCertificate, isPending: isDownloadingCertificate } =
|
||||||
|
trpc.document.downloadCertificate.useMutation();
|
||||||
|
|
||||||
|
const { mutateAsync: downloadAuditLogs, isPending: isDownloadingAuditLogs } =
|
||||||
|
trpc.document.downloadAuditLogs.useMutation();
|
||||||
|
|
||||||
const onDownloadClick = async () => {
|
const onDownloadClick = async () => {
|
||||||
try {
|
try {
|
||||||
const documentWithData = await trpcClient.document.getDocumentById.query(
|
const documentWithData = await trpcClient.document.getDocumentById.query(
|
||||||
@ -71,6 +84,125 @@ export const DocumentPageViewButton = ({ document }: DocumentPageViewButtonProps
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onDownloadOriginalClick = async () => {
|
||||||
|
try {
|
||||||
|
const documentWithData = await trpcClient.document.getDocumentById.query(
|
||||||
|
{
|
||||||
|
documentId: document.id,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
context: {
|
||||||
|
teamId: document.team?.id?.toString(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const documentData = documentWithData?.documentData;
|
||||||
|
|
||||||
|
if (!documentData) {
|
||||||
|
throw new Error('No document available');
|
||||||
|
}
|
||||||
|
|
||||||
|
await downloadPDF({ documentData, fileName: documentWithData.title, version: 'original' });
|
||||||
|
} catch (err) {
|
||||||
|
toast({
|
||||||
|
title: _(msg`Something went wrong`),
|
||||||
|
description: _(msg`An error occurred while downloading your document.`),
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDownloadCertificateClick = async () => {
|
||||||
|
try {
|
||||||
|
const { url } = await downloadCertificate({ documentId: document.id });
|
||||||
|
|
||||||
|
const iframe = Object.assign(window.document.createElement('iframe'), {
|
||||||
|
src: url,
|
||||||
|
});
|
||||||
|
|
||||||
|
Object.assign(iframe.style, {
|
||||||
|
position: 'fixed',
|
||||||
|
top: '0',
|
||||||
|
left: '0',
|
||||||
|
width: '0',
|
||||||
|
height: '0',
|
||||||
|
});
|
||||||
|
|
||||||
|
const onLoaded = () => {
|
||||||
|
if (iframe.contentDocument?.readyState === 'complete') {
|
||||||
|
iframe.contentWindow?.print();
|
||||||
|
|
||||||
|
iframe.contentWindow?.addEventListener('afterprint', () => {
|
||||||
|
window.document.body.removeChild(iframe);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// When the iframe has loaded, print the iframe and remove it from the dom
|
||||||
|
iframe.addEventListener('load', onLoaded);
|
||||||
|
|
||||||
|
window.document.body.appendChild(iframe);
|
||||||
|
|
||||||
|
onLoaded();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: _(msg`Something went wrong`),
|
||||||
|
description: _(
|
||||||
|
msg`Sorry, we were unable to download the certificate. Please try again later.`,
|
||||||
|
),
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDownloadAuditLogClick = async () => {
|
||||||
|
try {
|
||||||
|
const { url } = await downloadAuditLogs({ documentId: document.id });
|
||||||
|
|
||||||
|
const iframe = Object.assign(window.document.createElement('iframe'), {
|
||||||
|
src: url,
|
||||||
|
});
|
||||||
|
|
||||||
|
Object.assign(iframe.style, {
|
||||||
|
position: 'fixed',
|
||||||
|
top: '0',
|
||||||
|
left: '0',
|
||||||
|
width: '0',
|
||||||
|
height: '0',
|
||||||
|
});
|
||||||
|
|
||||||
|
const onLoaded = () => {
|
||||||
|
if (iframe.contentDocument?.readyState === 'complete') {
|
||||||
|
iframe.contentWindow?.print();
|
||||||
|
|
||||||
|
iframe.contentWindow?.addEventListener('afterprint', () => {
|
||||||
|
window.document.body.removeChild(iframe);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// When the iframe has loaded, print the iframe and remove it from the dom
|
||||||
|
iframe.addEventListener('load', onLoaded);
|
||||||
|
|
||||||
|
window.document.body.appendChild(iframe);
|
||||||
|
|
||||||
|
onLoaded();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: _(msg`Something went wrong`),
|
||||||
|
description: _(
|
||||||
|
msg`Sorry, we were unable to download the audit logs. Please try again later.`,
|
||||||
|
),
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return match({
|
return match({
|
||||||
isRecipient,
|
isRecipient,
|
||||||
isPending,
|
isPending,
|
||||||
@ -110,10 +242,26 @@ export const DocumentPageViewButton = ({ document }: DocumentPageViewButtonProps
|
|||||||
</Button>
|
</Button>
|
||||||
))
|
))
|
||||||
.with({ isComplete: true }, () => (
|
.with({ isComplete: true }, () => (
|
||||||
<Button className="w-full" onClick={onDownloadClick}>
|
<SplitButton className="w-full">
|
||||||
|
<SplitButtonAction onClick={onDownloadClick}>
|
||||||
<Download className="-ml-1 mr-2 inline h-4 w-4" />
|
<Download className="-ml-1 mr-2 inline h-4 w-4" />
|
||||||
<Trans>Download</Trans>
|
<Trans>Download</Trans>
|
||||||
</Button>
|
</SplitButtonAction>
|
||||||
|
|
||||||
|
<SplitButtonDropdown>
|
||||||
|
<SplitButtonDropdownItem onClick={onDownloadOriginalClick}>
|
||||||
|
<Trans>Download Original Document</Trans>
|
||||||
|
</SplitButtonDropdownItem>
|
||||||
|
|
||||||
|
<SplitButtonDropdownItem onClick={onDownloadCertificateClick}>
|
||||||
|
<Trans>Download Document Certificate</Trans>
|
||||||
|
</SplitButtonDropdownItem>
|
||||||
|
|
||||||
|
<SplitButtonDropdownItem onClick={onDownloadAuditLogClick}>
|
||||||
|
<Trans>Download Audit Log</Trans>
|
||||||
|
</SplitButtonDropdownItem>
|
||||||
|
</SplitButtonDropdown>
|
||||||
|
</SplitButton>
|
||||||
))
|
))
|
||||||
.otherwise(() => null);
|
.otherwise(() => null);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -10,6 +10,8 @@ import {
|
|||||||
Download,
|
Download,
|
||||||
Edit,
|
Edit,
|
||||||
EyeIcon,
|
EyeIcon,
|
||||||
|
FileDown,
|
||||||
|
FolderInput,
|
||||||
Loader,
|
Loader,
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
MoveRight,
|
MoveRight,
|
||||||
@ -182,7 +184,7 @@ export const DocumentsTableActionDropdown = ({
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
||||||
<DropdownMenuItem onClick={onDownloadOriginalClick}>
|
<DropdownMenuItem onClick={onDownloadOriginalClick}>
|
||||||
<Download className="mr-2 h-4 w-4" />
|
<FileDown className="mr-2 h-4 w-4" />
|
||||||
<Trans>Download Original</Trans>
|
<Trans>Download Original</Trans>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
||||||
@ -201,7 +203,7 @@ export const DocumentsTableActionDropdown = ({
|
|||||||
|
|
||||||
{onMoveDocument && (
|
{onMoveDocument && (
|
||||||
<DropdownMenuItem onClick={onMoveDocument} onSelect={(e) => e.preventDefault()}>
|
<DropdownMenuItem onClick={onMoveDocument} onSelect={(e) => e.preventDefault()}>
|
||||||
<MoveRight className="mr-2 h-4 w-4" />
|
<FolderInput className="mr-2 h-4 w-4" />
|
||||||
<Trans>Move to Folder</Trans>
|
<Trans>Move to Folder</Trans>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -69,8 +69,6 @@ export const TemplatesTableActionDropdown = ({
|
|||||||
? `${templateRootPath}/f/${row.folderId}/${row.id}/edit`
|
? `${templateRootPath}/f/${row.folderId}/${row.id}/edit`
|
||||||
: `${templateRootPath}/${row.id}/edit`;
|
: `${templateRootPath}/${row.id}/edit`;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger data-testid="template-table-action-btn">
|
<DropdownMenuTrigger data-testid="template-table-action-btn">
|
||||||
|
|||||||
@ -119,7 +119,7 @@ export default function CompletedSigningPage({ loaderData }: Route.ComponentProp
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'-mx-4 flex flex-col items-center overflow-x-hidden px-4 pt-24 md:-mx-8 md:px-8 lg:pt-36 xl:pt-44',
|
'-mx-4 flex flex-col items-center overflow-hidden px-4 pt-24 md:-mx-8 md:px-8 lg:pt-36 xl:pt-44',
|
||||||
{ 'pt-0 lg:pt-0 xl:pt-0': canSignUp },
|
{ 'pt-0 lg:pt-0 xl:pt-0': canSignUp },
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -40,6 +40,43 @@ services:
|
|||||||
entrypoint: sh
|
entrypoint: sh
|
||||||
command: -c 'mkdir -p /data/documenso && minio server /data --console-address ":9001" --address ":9002"'
|
command: -c 'mkdir -p /data/documenso && minio server /data --console-address ":9001" --address ":9002"'
|
||||||
|
|
||||||
|
triggerdotdev:
|
||||||
|
image: ghcr.io/triggerdotdev/trigger.dev:latest
|
||||||
|
container_name: triggerdotdev
|
||||||
|
environment:
|
||||||
|
- LOGIN_ORIGIN=http://localhost:3030
|
||||||
|
- APP_ORIGIN=http://localhost:3030
|
||||||
|
- PORT=3030
|
||||||
|
- REMIX_APP_PORT=3030
|
||||||
|
- MAGIC_LINK_SECRET=secret
|
||||||
|
- SESSION_SECRET=secret
|
||||||
|
- ENCRYPTION_KEY=deadbeefcafefeed
|
||||||
|
- DATABASE_URL=postgresql://trigger:password@triggerdotdev_database:5432/trigger
|
||||||
|
- DIRECT_URL=postgresql://trigger:password@triggerdotdev_database:5432/trigger
|
||||||
|
- RUNTIME_PLATFORM=docker-compose
|
||||||
|
ports:
|
||||||
|
- 3030:3030
|
||||||
|
depends_on:
|
||||||
|
- triggerdotdev_database
|
||||||
|
|
||||||
|
triggerdotdev_database:
|
||||||
|
container_name: triggerdotdev_database
|
||||||
|
image: postgres:15
|
||||||
|
volumes:
|
||||||
|
- triggerdotdev_database:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER}']
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
environment:
|
||||||
|
- POSTGRES_USER=trigger
|
||||||
|
- POSTGRES_PASSWORD=password
|
||||||
|
- POSTGRES_DB=trigger
|
||||||
|
ports:
|
||||||
|
- 54321:5432
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
minio:
|
minio:
|
||||||
documenso_database:
|
documenso_database:
|
||||||
|
triggerdotdev_database:
|
||||||
|
|||||||
4627
package-lock.json
generated
4627
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -28,6 +28,7 @@
|
|||||||
"with:env": "dotenv -e .env -e .env.local --",
|
"with:env": "dotenv -e .env -e .env.local --",
|
||||||
"reset:hard": "npm run clean && npm i && npm run prisma:generate",
|
"reset:hard": "npm run clean && npm i && npm run prisma:generate",
|
||||||
"precommit": "npm install && git add package.json package-lock.json",
|
"precommit": "npm install && git add package.json package-lock.json",
|
||||||
|
"trigger:dev": "npm run with:env -- npx trigger-cli dev --handler-path=\"/api/jobs\"",
|
||||||
"inngest:dev": "inngest dev -u http://localhost:3000/api/jobs",
|
"inngest:dev": "inngest dev -u http://localhost:3000/api/jobs",
|
||||||
"make:version": "npm version --workspace @documenso/remix --include-workspace-root --no-git-tag-version -m \"v%s\"",
|
"make:version": "npm version --workspace @documenso/remix --include-workspace-root --no-git-tag-version -m \"v%s\"",
|
||||||
"translate": "npm run translate:extract && npm run translate:compile",
|
"translate": "npm run translate:extract && npm run translate:compile",
|
||||||
@ -43,6 +44,7 @@
|
|||||||
"@commitlint/cli": "^17.7.1",
|
"@commitlint/cli": "^17.7.1",
|
||||||
"@commitlint/config-conventional": "^17.7.0",
|
"@commitlint/config-conventional": "^17.7.0",
|
||||||
"@lingui/cli": "^5.2.0",
|
"@lingui/cli": "^5.2.0",
|
||||||
|
"@trigger.dev/cli": "^2.3.18",
|
||||||
"dotenv": "^16.3.1",
|
"dotenv": "^16.3.1",
|
||||||
"dotenv-cli": "^7.3.0",
|
"dotenv-cli": "^7.3.0",
|
||||||
"eslint": "^8.40.0",
|
"eslint": "^8.40.0",
|
||||||
@ -74,5 +76,8 @@
|
|||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"zod": "3.24.1"
|
"zod": "3.24.1"
|
||||||
|
},
|
||||||
|
"trigger.dev": {
|
||||||
|
"endpointId": "documenso-app"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import {
|
|||||||
ZDeleteDocumentMutationSchema,
|
ZDeleteDocumentMutationSchema,
|
||||||
ZDeleteFieldMutationSchema,
|
ZDeleteFieldMutationSchema,
|
||||||
ZDeleteRecipientMutationSchema,
|
ZDeleteRecipientMutationSchema,
|
||||||
|
ZDownloadDocumentQuerySchema,
|
||||||
ZDownloadDocumentSuccessfulSchema,
|
ZDownloadDocumentSuccessfulSchema,
|
||||||
ZFindTeamMembersResponseSchema,
|
ZFindTeamMembersResponseSchema,
|
||||||
ZGenerateDocumentFromTemplateMutationResponseSchema,
|
ZGenerateDocumentFromTemplateMutationResponseSchema,
|
||||||
@ -71,6 +72,7 @@ export const ApiContractV1 = c.router(
|
|||||||
downloadSignedDocument: {
|
downloadSignedDocument: {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
path: '/api/v1/documents/:id/download',
|
path: '/api/v1/documents/:id/download',
|
||||||
|
query: ZDownloadDocumentQuerySchema,
|
||||||
responses: {
|
responses: {
|
||||||
200: ZDownloadDocumentSuccessfulSchema,
|
200: ZDownloadDocumentSuccessfulSchema,
|
||||||
401: ZUnsuccessfulResponseSchema,
|
401: ZUnsuccessfulResponseSchema,
|
||||||
|
|||||||
@ -142,6 +142,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
|
|||||||
|
|
||||||
downloadSignedDocument: authenticatedMiddleware(async (args, user, team) => {
|
downloadSignedDocument: authenticatedMiddleware(async (args, user, team) => {
|
||||||
const { id: documentId } = args.params;
|
const { id: documentId } = args.params;
|
||||||
|
const { downloadOriginalDocument } = args.query;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (process.env.NEXT_PUBLIC_UPLOAD_TRANSPORT !== 's3') {
|
if (process.env.NEXT_PUBLIC_UPLOAD_TRANSPORT !== 's3') {
|
||||||
@ -177,7 +178,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isDocumentCompleted(document.status)) {
|
if (!downloadOriginalDocument && !isDocumentCompleted(document.status)) {
|
||||||
return {
|
return {
|
||||||
status: 400,
|
status: 400,
|
||||||
body: {
|
body: {
|
||||||
@ -186,7 +187,9 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const { url } = await getPresignGetUrl(document.documentData.data);
|
const { url } = await getPresignGetUrl(
|
||||||
|
downloadOriginalDocument ? document.documentData.data : document.documentData.initialData,
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
status: 200,
|
status: 200,
|
||||||
|
|||||||
@ -119,6 +119,15 @@ export const ZUploadDocumentSuccessfulSchema = z.object({
|
|||||||
key: z.string(),
|
key: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const ZDownloadDocumentQuerySchema = z.object({
|
||||||
|
downloadOriginalDocument: z
|
||||||
|
.preprocess((val) => String(val) === 'true' || String(val) === '1', z.boolean())
|
||||||
|
.optional()
|
||||||
|
.default(false),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TDownloadDocumentQuerySchema = z.infer<typeof ZDownloadDocumentQuerySchema>;
|
||||||
|
|
||||||
export const ZDownloadDocumentSuccessfulSchema = z.object({
|
export const ZDownloadDocumentSuccessfulSchema = z.object({
|
||||||
downloadUrl: z.string(),
|
downloadUrl: z.string(),
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,19 +1,13 @@
|
|||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
import { env } from '../../utils/env';
|
import { env } from '../../utils/env';
|
||||||
import type {
|
import type { JobDefinition, TriggerJobOptions } from './_internal/job';
|
||||||
JobDefinition,
|
|
||||||
JobRunIO,
|
|
||||||
SimpleTriggerJobOptions,
|
|
||||||
TriggerJobOptions,
|
|
||||||
} from './_internal/job';
|
|
||||||
import type { BaseJobProvider as JobClientProvider } from './base';
|
import type { BaseJobProvider as JobClientProvider } from './base';
|
||||||
import { InngestJobProvider } from './inngest';
|
import { InngestJobProvider } from './inngest';
|
||||||
import { LocalJobProvider } from './local';
|
import { LocalJobProvider } from './local';
|
||||||
|
|
||||||
export class JobClient<T extends ReadonlyArray<JobDefinition> = []> {
|
export class JobClient<T extends ReadonlyArray<JobDefinition> = []> {
|
||||||
private _provider: JobClientProvider;
|
private _provider: JobClientProvider;
|
||||||
private _jobDefinitions: Record<string, JobDefinition> = {};
|
|
||||||
|
|
||||||
public constructor(definitions: T) {
|
public constructor(definitions: T) {
|
||||||
this._provider = match(env('NEXT_PRIVATE_JOBS_PROVIDER'))
|
this._provider = match(env('NEXT_PRIVATE_JOBS_PROVIDER'))
|
||||||
@ -22,67 +16,10 @@ export class JobClient<T extends ReadonlyArray<JobDefinition> = []> {
|
|||||||
|
|
||||||
definitions.forEach((definition) => {
|
definitions.forEach((definition) => {
|
||||||
this._provider.defineJob(definition);
|
this._provider.defineJob(definition);
|
||||||
this._jobDefinitions[definition.id] = definition;
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
public async triggerJob(options: TriggerJobOptions<T>) {
|
||||||
* Check if Inngest background job processing is available.
|
|
||||||
*
|
|
||||||
* For Inngest to be considered available:
|
|
||||||
* 1. NEXT_PRIVATE_JOBS_PROVIDER must be set to 'inngest'
|
|
||||||
* 2. Either INNGEST_EVENT_KEY or NEXT_PRIVATE_INNGEST_EVENT_KEY must be provided
|
|
||||||
*
|
|
||||||
* If Inngest is not available, jobs will be executed synchronously without background scheduling.
|
|
||||||
*/
|
|
||||||
public isInngestAvailable(): boolean {
|
|
||||||
return (
|
|
||||||
env('NEXT_PRIVATE_JOBS_PROVIDER') === 'inngest' &&
|
|
||||||
Boolean(env('INNGEST_EVENT_KEY') || env('NEXT_PRIVATE_INNGEST_EVENT_KEY'))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async triggerJob(options: TriggerJobOptions<T>): Promise<unknown> {
|
|
||||||
// When Inngest is not available, execute the job directly
|
|
||||||
if (!this.isInngestAvailable()) {
|
|
||||||
const eligibleJob = Object.values(this._jobDefinitions).find(
|
|
||||||
(job) => job.trigger.name === options.name,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (eligibleJob && eligibleJob.handler) {
|
|
||||||
// Execute the job directly without scheduling
|
|
||||||
const payload = options.payload;
|
|
||||||
const io: JobRunIO = {
|
|
||||||
wait: async (_cacheKey: string, ms: number): Promise<void> => {
|
|
||||||
// Create a Promise that resolves after ms milliseconds
|
|
||||||
return new Promise<void>((resolve) => {
|
|
||||||
setTimeout(resolve, ms);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
logger: console,
|
|
||||||
runTask: async <R>(cacheKey: string, callback: () => Promise<R>): Promise<R> => {
|
|
||||||
return await callback();
|
|
||||||
},
|
|
||||||
triggerJob: async (
|
|
||||||
_cacheKey: string,
|
|
||||||
jobOptions: SimpleTriggerJobOptions,
|
|
||||||
): Promise<unknown> => {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
|
||||||
return await this.triggerJob(jobOptions as TriggerJobOptions<T>);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await eligibleJob.handler({ payload, io });
|
|
||||||
return result;
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Direct job execution failed for ${options.name}:`, error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use background processing with Inngest if available
|
|
||||||
return this._provider.triggerJob(options);
|
return this._provider.triggerJob(options);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -32,49 +32,16 @@ export const run = async ({
|
|||||||
}) => {
|
}) => {
|
||||||
const { userId, documentId, recipientId, requestMetadata } = payload;
|
const { userId, documentId, recipientId, requestMetadata } = payload;
|
||||||
|
|
||||||
try {
|
const [user, document, recipient] = await Promise.all([
|
||||||
// First, check if the document exists directly before performing the multi-promise
|
|
||||||
const documentExists = await prisma.document.findFirst({
|
|
||||||
where: {
|
|
||||||
id: documentId,
|
|
||||||
},
|
|
||||||
select: { id: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!documentExists) {
|
|
||||||
throw new Error(`No Document found with ID ${documentId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add a small delay to allow any pending transactions to complete
|
|
||||||
await new Promise<void>((resolve) => {
|
|
||||||
setTimeout(() => {
|
|
||||||
resolve();
|
|
||||||
}, 100);
|
|
||||||
});
|
|
||||||
|
|
||||||
const [user, recipient] = await Promise.all([
|
|
||||||
prisma.user.findFirstOrThrow({
|
prisma.user.findFirstOrThrow({
|
||||||
where: {
|
where: {
|
||||||
id: userId,
|
id: userId,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
prisma.document.findFirstOrThrow({
|
||||||
prisma.recipient.findFirstOrThrow({
|
|
||||||
where: {
|
|
||||||
id: recipientId,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Get the document without restricting to PENDING status
|
|
||||||
const document = await prisma.document.findFirstOrThrow({
|
|
||||||
where: {
|
where: {
|
||||||
id: documentId,
|
id: documentId,
|
||||||
// Don't restrict to PENDING status, as it might still be in DRAFT status
|
status: DocumentStatus.PENDING,
|
||||||
// if the transaction hasn't fully completed yet
|
|
||||||
status: {
|
|
||||||
in: [DocumentStatus.DRAFT, DocumentStatus.PENDING],
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
documentMeta: true,
|
documentMeta: true,
|
||||||
@ -86,7 +53,13 @@ export const run = async ({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
}),
|
||||||
|
prisma.recipient.findFirstOrThrow({
|
||||||
|
where: {
|
||||||
|
id: recipientId,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
const { documentMeta, team } = document;
|
const { documentMeta, team } = document;
|
||||||
|
|
||||||
@ -236,8 +209,4 @@ export const run = async ({
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
} catch (error) {
|
|
||||||
console.error(`Job failed with error:`, error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|||||||
@ -16,6 +16,7 @@ const SEND_TEAM_DELETED_EMAIL_JOB_DEFINITION_SCHEMA = z.object({
|
|||||||
documentLanguage: z.string(),
|
documentLanguage: z.string(),
|
||||||
includeSenderDetails: z.boolean(),
|
includeSenderDetails: z.boolean(),
|
||||||
includeSigningCertificate: z.boolean(),
|
includeSigningCertificate: z.boolean(),
|
||||||
|
includeAuditLog: z.boolean(),
|
||||||
brandingEnabled: z.boolean(),
|
brandingEnabled: z.boolean(),
|
||||||
brandingLogo: z.string(),
|
brandingLogo: z.string(),
|
||||||
brandingUrl: z.string(),
|
brandingUrl: z.string(),
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import { signPdf } from '@documenso/signing';
|
|||||||
import { AppError, AppErrorCode } from '../../../errors/app-error';
|
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 { getAuditLogPdf } from '../../../server-only/htmltopdf/get-audit-log-pdf';
|
||||||
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 { 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';
|
||||||
@ -52,6 +53,7 @@ export const run = async ({
|
|||||||
teamGlobalSettings: {
|
teamGlobalSettings: {
|
||||||
select: {
|
select: {
|
||||||
includeSigningCertificate: true,
|
includeSigningCertificate: true,
|
||||||
|
includeAuditLog: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -152,6 +154,13 @@ export const run = async ({
|
|||||||
}).catch(() => null)
|
}).catch(() => null)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
const auditLogData =
|
||||||
|
(document.team?.teamGlobalSettings?.includeAuditLog ?? false)
|
||||||
|
? await getAuditLogPdf({
|
||||||
|
documentId,
|
||||||
|
}).catch(() => null)
|
||||||
|
: null;
|
||||||
|
|
||||||
const newDataId = await io.runTask('decorate-and-sign-pdf', async () => {
|
const newDataId = await io.runTask('decorate-and-sign-pdf', async () => {
|
||||||
const pdfDoc = await PDFDocument.load(pdfData);
|
const pdfDoc = await PDFDocument.load(pdfData);
|
||||||
|
|
||||||
@ -178,6 +187,16 @@ export const run = async ({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (auditLogData) {
|
||||||
|
const auditLogDoc = await PDFDocument.load(auditLogData);
|
||||||
|
|
||||||
|
const auditLogPages = await pdfDoc.copyPages(auditLogDoc, auditLogDoc.getPageIndices());
|
||||||
|
|
||||||
|
auditLogPages.forEach((page) => {
|
||||||
|
pdfDoc.addPage(page);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
for (const field of fields) {
|
for (const field of fields) {
|
||||||
if (field.inserted) {
|
if (field.inserted) {
|
||||||
document.useLegacyFieldInsertion
|
document.useLegacyFieldInsertion
|
||||||
|
|||||||
@ -28,6 +28,7 @@
|
|||||||
"@lingui/core": "^5.2.0",
|
"@lingui/core": "^5.2.0",
|
||||||
"@lingui/macro": "^5.2.0",
|
"@lingui/macro": "^5.2.0",
|
||||||
"@lingui/react": "^5.2.0",
|
"@lingui/react": "^5.2.0",
|
||||||
|
"jose": "^6.0.0",
|
||||||
"@noble/ciphers": "0.4.0",
|
"@noble/ciphers": "0.4.0",
|
||||||
"@noble/hashes": "1.3.2",
|
"@noble/hashes": "1.3.2",
|
||||||
"@node-rs/bcrypt": "^1.10.0",
|
"@node-rs/bcrypt": "^1.10.0",
|
||||||
@ -37,7 +38,6 @@
|
|||||||
"@vvo/tzdb": "^6.117.0",
|
"@vvo/tzdb": "^6.117.0",
|
||||||
"csv-parse": "^5.6.0",
|
"csv-parse": "^5.6.0",
|
||||||
"inngest": "^3.19.13",
|
"inngest": "^3.19.13",
|
||||||
"jose": "^6.0.0",
|
|
||||||
"kysely": "0.26.3",
|
"kysely": "0.26.3",
|
||||||
"luxon": "^3.4.0",
|
"luxon": "^3.4.0",
|
||||||
"micro": "^10.0.1",
|
"micro": "^10.0.1",
|
||||||
@ -47,7 +47,6 @@
|
|||||||
"pg": "^8.11.3",
|
"pg": "^8.11.3",
|
||||||
"playwright": "1.43.0",
|
"playwright": "1.43.0",
|
||||||
"posthog-js": "^1.224.0",
|
"posthog-js": "^1.224.0",
|
||||||
"posthog-node": "^4.17.1",
|
|
||||||
"react": "^18",
|
"react": "^18",
|
||||||
"remeda": "^2.17.3",
|
"remeda": "^2.17.3",
|
||||||
"sharp": "0.32.6",
|
"sharp": "0.32.6",
|
||||||
|
|||||||
@ -1,9 +1,14 @@
|
|||||||
import { DocumentSource, type Prisma } from '@prisma/client';
|
import { DocumentSource, type Prisma, WebhookTriggerEvents } from '@prisma/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 {
|
||||||
|
ZWebhookDocumentSchema,
|
||||||
|
mapDocumentToWebhookDocumentPayload,
|
||||||
|
} from '../../types/webhook-payload';
|
||||||
import { prefixedId } from '../../universal/id';
|
import { prefixedId } from '../../universal/id';
|
||||||
|
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||||
import { getDocumentWhereInput } from './get-document-by-id';
|
import { getDocumentWhereInput } from './get-document-by-id';
|
||||||
|
|
||||||
export interface DuplicateDocumentOptions {
|
export interface DuplicateDocumentOptions {
|
||||||
@ -86,7 +91,24 @@ export const duplicateDocument = async ({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const createdDocument = await prisma.document.create(createDocumentArguments);
|
const createdDocument = await prisma.document.create({
|
||||||
|
...createDocumentArguments,
|
||||||
|
include: {
|
||||||
|
recipients: true,
|
||||||
|
documentMeta: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await triggerWebhook({
|
||||||
|
event: WebhookTriggerEvents.DOCUMENT_CREATED,
|
||||||
|
data: ZWebhookDocumentSchema.parse({
|
||||||
|
...mapDocumentToWebhookDocumentPayload(createdDocument),
|
||||||
|
recipients: createdDocument.recipients,
|
||||||
|
documentMeta: createdDocument.documentMeta,
|
||||||
|
}),
|
||||||
|
userId: userId,
|
||||||
|
teamId: teamId,
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
documentId: createdDocument.id,
|
documentId: createdDocument.id,
|
||||||
|
|||||||
@ -17,6 +17,7 @@ import type { RequestMetadata } from '../../universal/extract-request-metadata';
|
|||||||
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 { getAuditLogPdf } from '../htmltopdf/get-audit-log-pdf';
|
||||||
import { getCertificatePdf } from '../htmltopdf/get-certificate-pdf';
|
import { getCertificatePdf } from '../htmltopdf/get-certificate-pdf';
|
||||||
import { addRejectionStampToPdf } from '../pdf/add-rejection-stamp-to-pdf';
|
import { addRejectionStampToPdf } from '../pdf/add-rejection-stamp-to-pdf';
|
||||||
import { flattenAnnotations } from '../pdf/flatten-annotations';
|
import { flattenAnnotations } from '../pdf/flatten-annotations';
|
||||||
@ -53,6 +54,7 @@ export const sealDocument = async ({
|
|||||||
teamGlobalSettings: {
|
teamGlobalSettings: {
|
||||||
select: {
|
select: {
|
||||||
includeSigningCertificate: true,
|
includeSigningCertificate: true,
|
||||||
|
includeAuditLog: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -124,6 +126,13 @@ export const sealDocument = async ({
|
|||||||
}).catch(() => null)
|
}).catch(() => null)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
const auditLogData =
|
||||||
|
(document.team?.teamGlobalSettings?.includeAuditLog ?? false)
|
||||||
|
? await getAuditLogPdf({
|
||||||
|
documentId,
|
||||||
|
}).catch(() => null)
|
||||||
|
: null;
|
||||||
|
|
||||||
const doc = await PDFDocument.load(pdfData);
|
const doc = await PDFDocument.load(pdfData);
|
||||||
|
|
||||||
// Normalize and flatten layers that could cause issues with the signature
|
// Normalize and flatten layers that could cause issues with the signature
|
||||||
@ -146,6 +155,16 @@ export const sealDocument = async ({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (auditLogData) {
|
||||||
|
const auditLog = await PDFDocument.load(auditLogData);
|
||||||
|
|
||||||
|
const auditLogPages = await doc.copyPages(auditLog, auditLog.getPageIndices());
|
||||||
|
|
||||||
|
auditLogPages.forEach((page) => {
|
||||||
|
doc.addPage(page);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
for (const field of fields) {
|
for (const field of fields) {
|
||||||
document.useLegacyFieldInsertion
|
document.useLegacyFieldInsertion
|
||||||
? await legacy_insertFieldInPDF(doc, field)
|
? await legacy_insertFieldInPDF(doc, field)
|
||||||
|
|||||||
@ -133,6 +133,31 @@ export const sendDocument = async ({
|
|||||||
Object.assign(document, result);
|
Object.assign(document, result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Commented out server side checks for minimum 1 signature per signer now since we need to
|
||||||
|
// decide if we want to enforce this for API & templates.
|
||||||
|
// const fields = await getFieldsForDocument({
|
||||||
|
// documentId: documentId,
|
||||||
|
// userId: userId,
|
||||||
|
// });
|
||||||
|
|
||||||
|
// const fieldsWithSignerEmail = fields.map((field) => ({
|
||||||
|
// ...field,
|
||||||
|
// signerEmail:
|
||||||
|
// document.Recipient.find((recipient) => recipient.id === field.recipientId)?.email ?? '',
|
||||||
|
// }));
|
||||||
|
|
||||||
|
// const everySignerHasSignature = document?.Recipient.every(
|
||||||
|
// (recipient) =>
|
||||||
|
// recipient.role !== RecipientRole.SIGNER ||
|
||||||
|
// fieldsWithSignerEmail.some(
|
||||||
|
// (field) => field.type === 'SIGNATURE' && field.signerEmail === recipient.email,
|
||||||
|
// ),
|
||||||
|
// );
|
||||||
|
|
||||||
|
// if (!everySignerHasSignature) {
|
||||||
|
// throw new Error('Some signers have not been assigned a signature field.');
|
||||||
|
// }
|
||||||
|
|
||||||
const isRecipientSigningRequestEmailEnabled = extractDerivedDocumentEmailSettings(
|
const isRecipientSigningRequestEmailEnabled = extractDerivedDocumentEmailSettings(
|
||||||
document.documentMeta,
|
document.documentMeta,
|
||||||
).recipientSigningRequest;
|
).recipientSigningRequest;
|
||||||
@ -140,14 +165,52 @@ export const sendDocument = async ({
|
|||||||
// Only send email if one of the following is true:
|
// Only send email if one of the following is true:
|
||||||
// - It is explicitly set
|
// - It is explicitly set
|
||||||
// - The email is enabled for signing requests AND sendEmail is undefined
|
// - The email is enabled for signing requests AND sendEmail is undefined
|
||||||
const shouldSendEmail =
|
if (sendEmail || (isRecipientSigningRequestEmailEnabled && sendEmail === undefined)) {
|
||||||
sendEmail || (isRecipientSigningRequestEmailEnabled && sendEmail === undefined);
|
await Promise.all(
|
||||||
|
recipientsToNotify.map(async (recipient) => {
|
||||||
|
if (recipient.sendStatus === SendStatus.SENT || recipient.role === RecipientRole.CC) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await jobs.triggerJob({
|
||||||
|
name: 'send.signing.requested.email',
|
||||||
|
payload: {
|
||||||
|
userId,
|
||||||
|
documentId,
|
||||||
|
recipientId: recipient.id,
|
||||||
|
requestMetadata: requestMetadata?.requestMetadata,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const allRecipientsHaveNoActionToTake = document.recipients.every(
|
const allRecipientsHaveNoActionToTake = document.recipients.every(
|
||||||
(recipient) =>
|
(recipient) =>
|
||||||
recipient.role === RecipientRole.CC || recipient.signingStatus === SigningStatus.SIGNED,
|
recipient.role === RecipientRole.CC || recipient.signingStatus === SigningStatus.SIGNED,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (allRecipientsHaveNoActionToTake) {
|
||||||
|
await jobs.triggerJob({
|
||||||
|
name: 'internal.seal-document',
|
||||||
|
payload: {
|
||||||
|
documentId,
|
||||||
|
requestMetadata: requestMetadata?.requestMetadata,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Keep the return type the same for the `sendDocument` method
|
||||||
|
return await prisma.document.findFirstOrThrow({
|
||||||
|
where: {
|
||||||
|
id: documentId,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
documentMeta: true,
|
||||||
|
recipients: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const updatedDocument = await prisma.$transaction(async (tx) => {
|
const updatedDocument = await prisma.$transaction(async (tx) => {
|
||||||
if (document.status === DocumentStatus.DRAFT) {
|
if (document.status === DocumentStatus.DRAFT) {
|
||||||
await tx.documentAuditLog.create({
|
await tx.documentAuditLog.create({
|
||||||
@ -181,47 +244,5 @@ export const sendDocument = async ({
|
|||||||
teamId,
|
teamId,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Now that the transaction is complete and document status is updated, trigger email jobs
|
|
||||||
if (shouldSendEmail) {
|
|
||||||
await Promise.all(
|
|
||||||
recipientsToNotify.map(async (recipient) => {
|
|
||||||
if (recipient.sendStatus === SendStatus.SENT || recipient.role === RecipientRole.CC) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await jobs.triggerJob({
|
|
||||||
name: 'send.signing.requested.email',
|
|
||||||
payload: {
|
|
||||||
userId,
|
|
||||||
documentId,
|
|
||||||
recipientId: recipient.id,
|
|
||||||
requestMetadata: requestMetadata?.requestMetadata,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (allRecipientsHaveNoActionToTake) {
|
|
||||||
await jobs.triggerJob({
|
|
||||||
name: 'internal.seal-document',
|
|
||||||
payload: {
|
|
||||||
documentId,
|
|
||||||
requestMetadata: requestMetadata?.requestMetadata,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Keep the return type the same for the `sendDocument` method
|
|
||||||
return await prisma.document.findFirstOrThrow({
|
|
||||||
where: {
|
|
||||||
id: documentId,
|
|
||||||
},
|
|
||||||
include: {
|
|
||||||
documentMeta: true,
|
|
||||||
recipients: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return updatedDocument;
|
return updatedDocument;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { DocumentStatus, DocumentVisibility, TeamMemberRole } from '@prisma/client';
|
import { DocumentVisibility } from '@prisma/client';
|
||||||
|
import { DocumentStatus, TeamMemberRole } from '@prisma/client';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
|
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
|
||||||
@ -118,6 +119,7 @@ export const updateDocument = async ({
|
|||||||
|
|
||||||
// If no data just return the document since this function is normally chained after a meta update.
|
// If no data just return the document since this function is normally chained after a meta update.
|
||||||
if (!data || Object.values(data).length === 0) {
|
if (!data || Object.values(data).length === 0) {
|
||||||
|
console.log('no data');
|
||||||
return document;
|
return document;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
69
packages/lib/server-only/htmltopdf/get-audit-log-pdf.ts
Normal file
69
packages/lib/server-only/htmltopdf/get-audit-log-pdf.ts
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
import { DateTime } from 'luxon';
|
||||||
|
import type { Browser } from 'playwright';
|
||||||
|
|
||||||
|
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
|
||||||
|
import { type SupportedLanguageCodes, isValidLanguageCode } from '../../constants/i18n';
|
||||||
|
import { env } from '../../utils/env';
|
||||||
|
import { encryptSecondaryData } from '../crypto/encrypt';
|
||||||
|
|
||||||
|
export type GetAuditLogPdfOptions = {
|
||||||
|
documentId: number;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||||
|
language?: SupportedLanguageCodes | (string & {});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getAuditLogPdf = async ({ documentId, language }: GetAuditLogPdfOptions) => {
|
||||||
|
const { chromium } = await import('playwright');
|
||||||
|
|
||||||
|
const encryptedId = encryptSecondaryData({
|
||||||
|
data: documentId.toString(),
|
||||||
|
expiresAt: DateTime.now().plus({ minutes: 5 }).toJSDate().valueOf(),
|
||||||
|
});
|
||||||
|
|
||||||
|
let browser: Browser;
|
||||||
|
|
||||||
|
const browserlessUrl = env('NEXT_PRIVATE_BROWSERLESS_URL');
|
||||||
|
|
||||||
|
if (browserlessUrl) {
|
||||||
|
// !: Use CDP rather than the default `connect` method to avoid coupling to the playwright version.
|
||||||
|
// !: Previously we would have to keep the playwright version in sync with the browserless version to avoid errors.
|
||||||
|
browser = await chromium.connectOverCDP(browserlessUrl);
|
||||||
|
} else {
|
||||||
|
browser = await chromium.launch();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!browser) {
|
||||||
|
throw new Error(
|
||||||
|
'Failed to establish a browser, please ensure you have either a Browserless.io url or chromium browser installed',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const browserContext = await browser.newContext();
|
||||||
|
|
||||||
|
const page = await browserContext.newPage();
|
||||||
|
|
||||||
|
const lang = isValidLanguageCode(language) ? language : 'en';
|
||||||
|
|
||||||
|
await page.context().addCookies([
|
||||||
|
{
|
||||||
|
name: 'language',
|
||||||
|
value: lang,
|
||||||
|
url: NEXT_PUBLIC_WEBAPP_URL(),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/__htmltopdf/audit-log?d=${encryptedId}`, {
|
||||||
|
waitUntil: 'networkidle',
|
||||||
|
timeout: 10_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await page.pdf({
|
||||||
|
format: 'A4',
|
||||||
|
});
|
||||||
|
|
||||||
|
await browserContext.close();
|
||||||
|
|
||||||
|
void browser.close();
|
||||||
|
|
||||||
|
return result;
|
||||||
|
};
|
||||||
@ -9,11 +9,13 @@ import {
|
|||||||
SigningStatus,
|
SigningStatus,
|
||||||
WebhookTriggerEvents,
|
WebhookTriggerEvents,
|
||||||
} from '@prisma/client';
|
} from '@prisma/client';
|
||||||
|
import { DateTime } from 'luxon';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
import { nanoid, prefixedId } from '@documenso/lib/universal/id';
|
import { nanoid, prefixedId } from '@documenso/lib/universal/id';
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
|
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '../../constants/date-formats';
|
||||||
import type { SupportedLanguageCodes } from '../../constants/i18n';
|
import type { SupportedLanguageCodes } from '../../constants/i18n';
|
||||||
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';
|
||||||
@ -508,10 +510,8 @@ export const createDocumentFromTemplate = async ({
|
|||||||
fieldsToCreate = fieldsToCreate.concat(
|
fieldsToCreate = fieldsToCreate.concat(
|
||||||
fields.map((field) => {
|
fields.map((field) => {
|
||||||
const prefillField = prefillFields?.find((value) => value.id === field.id);
|
const prefillField = prefillFields?.find((value) => value.id === field.id);
|
||||||
// Use type assertion to help TypeScript understand the structure
|
|
||||||
const updatedFieldMeta = getUpdatedFieldMeta(field, prefillField);
|
|
||||||
|
|
||||||
return {
|
const payload = {
|
||||||
documentId: document.id,
|
documentId: document.id,
|
||||||
recipientId: recipient.id,
|
recipientId: recipient.id,
|
||||||
type: field.type,
|
type: field.type,
|
||||||
@ -522,8 +522,38 @@ export const createDocumentFromTemplate = async ({
|
|||||||
height: field.height,
|
height: field.height,
|
||||||
customText: '',
|
customText: '',
|
||||||
inserted: false,
|
inserted: false,
|
||||||
fieldMeta: updatedFieldMeta,
|
fieldMeta: field.fieldMeta,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (prefillField) {
|
||||||
|
match(prefillField)
|
||||||
|
.with({ type: 'date' }, (selector) => {
|
||||||
|
if (!selector.value) {
|
||||||
|
throw new AppError(AppErrorCode.INVALID_BODY, {
|
||||||
|
message: `Date value is required for field ${field.id}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const date = new Date(selector.value);
|
||||||
|
|
||||||
|
if (isNaN(date.getTime())) {
|
||||||
|
throw new AppError(AppErrorCode.INVALID_BODY, {
|
||||||
|
message: `Invalid date value for field ${field.id}: ${selector.value}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
payload.customText = DateTime.fromJSDate(date).toFormat(
|
||||||
|
template.templateMeta?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT,
|
||||||
|
);
|
||||||
|
|
||||||
|
payload.inserted = true;
|
||||||
|
})
|
||||||
|
.otherwise((selector) => {
|
||||||
|
payload.fieldMeta = getUpdatedFieldMeta(field, selector);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload;
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -155,6 +155,10 @@ export const ZFieldMetaPrefillFieldsSchema = z
|
|||||||
label: z.string().optional(),
|
label: z.string().optional(),
|
||||||
value: z.string().optional(),
|
value: z.string().optional(),
|
||||||
}),
|
}),
|
||||||
|
z.object({
|
||||||
|
type: z.literal('date'),
|
||||||
|
value: z.string().optional(),
|
||||||
|
}),
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "TeamGlobalSettings" ADD COLUMN "includeAuditLog" BOOLEAN NOT NULL DEFAULT false;
|
||||||
@ -586,7 +586,9 @@ model TeamGlobalSettings {
|
|||||||
documentVisibility DocumentVisibility @default(EVERYONE)
|
documentVisibility DocumentVisibility @default(EVERYONE)
|
||||||
documentLanguage String @default("en")
|
documentLanguage String @default("en")
|
||||||
includeSenderDetails Boolean @default(true)
|
includeSenderDetails Boolean @default(true)
|
||||||
|
|
||||||
includeSigningCertificate Boolean @default(true)
|
includeSigningCertificate Boolean @default(true)
|
||||||
|
includeAuditLog Boolean @default(false)
|
||||||
|
|
||||||
typedSignatureEnabled Boolean @default(true)
|
typedSignatureEnabled Boolean @default(true)
|
||||||
uploadSignatureEnabled Boolean @default(true)
|
uploadSignatureEnabled Boolean @default(true)
|
||||||
|
|||||||
@ -23,6 +23,7 @@ export const updateTeamDocumentSettingsRoute = authenticatedProcedure
|
|||||||
documentLanguage,
|
documentLanguage,
|
||||||
includeSenderDetails,
|
includeSenderDetails,
|
||||||
includeSigningCertificate,
|
includeSigningCertificate,
|
||||||
|
includeAuditLog,
|
||||||
typedSignatureEnabled,
|
typedSignatureEnabled,
|
||||||
uploadSignatureEnabled,
|
uploadSignatureEnabled,
|
||||||
drawSignatureEnabled,
|
drawSignatureEnabled,
|
||||||
@ -54,6 +55,7 @@ export const updateTeamDocumentSettingsRoute = authenticatedProcedure
|
|||||||
documentLanguage,
|
documentLanguage,
|
||||||
includeSenderDetails,
|
includeSenderDetails,
|
||||||
includeSigningCertificate,
|
includeSigningCertificate,
|
||||||
|
includeAuditLog,
|
||||||
typedSignatureEnabled,
|
typedSignatureEnabled,
|
||||||
uploadSignatureEnabled,
|
uploadSignatureEnabled,
|
||||||
drawSignatureEnabled,
|
drawSignatureEnabled,
|
||||||
@ -63,6 +65,7 @@ export const updateTeamDocumentSettingsRoute = authenticatedProcedure
|
|||||||
documentLanguage,
|
documentLanguage,
|
||||||
includeSenderDetails,
|
includeSenderDetails,
|
||||||
includeSigningCertificate,
|
includeSigningCertificate,
|
||||||
|
includeAuditLog,
|
||||||
typedSignatureEnabled,
|
typedSignatureEnabled,
|
||||||
uploadSignatureEnabled,
|
uploadSignatureEnabled,
|
||||||
drawSignatureEnabled,
|
drawSignatureEnabled,
|
||||||
|
|||||||
@ -14,6 +14,7 @@ export const ZUpdateTeamDocumentSettingsRequestSchema = z.object({
|
|||||||
documentLanguage: z.enum(SUPPORTED_LANGUAGE_CODES).optional().default('en'),
|
documentLanguage: z.enum(SUPPORTED_LANGUAGE_CODES).optional().default('en'),
|
||||||
includeSenderDetails: z.boolean().optional().default(false),
|
includeSenderDetails: z.boolean().optional().default(false),
|
||||||
includeSigningCertificate: z.boolean().optional().default(true),
|
includeSigningCertificate: z.boolean().optional().default(true),
|
||||||
|
includeAuditLog: z.boolean().optional().default(false),
|
||||||
typedSignatureEnabled: z.boolean().optional().default(true),
|
typedSignatureEnabled: z.boolean().optional().default(true),
|
||||||
uploadSignatureEnabled: z.boolean().optional().default(true),
|
uploadSignatureEnabled: z.boolean().optional().default(true),
|
||||||
drawSignatureEnabled: z.boolean().optional().default(true),
|
drawSignatureEnabled: z.boolean().optional().default(true),
|
||||||
|
|||||||
@ -24,7 +24,7 @@ export const SigningCard = ({
|
|||||||
signingCelebrationImage,
|
signingCelebrationImage,
|
||||||
}: SigningCardProps) => {
|
}: SigningCardProps) => {
|
||||||
return (
|
return (
|
||||||
<div className={cn('relative w-full max-w-xs md:max-w-sm', className)}>
|
<div className={cn('relative w-full max-w-sm md:max-w-md', className)}>
|
||||||
<SigningCardContent name={name} signature={signature} />
|
<SigningCardContent name={name} signature={signature} />
|
||||||
|
|
||||||
{signingCelebrationImage && (
|
{signingCelebrationImage && (
|
||||||
@ -48,7 +48,7 @@ export const SigningCard3D = ({
|
|||||||
|
|
||||||
const [trackMouse, setTrackMouse] = useState(false);
|
const [trackMouse, setTrackMouse] = useState(false);
|
||||||
|
|
||||||
const timeoutRef = useRef<NodeJS.Timeout>();
|
const timeoutRef = useRef<number | undefined>();
|
||||||
|
|
||||||
const cardX = useMotionValue(0);
|
const cardX = useMotionValue(0);
|
||||||
const cardY = useMotionValue(0);
|
const cardY = useMotionValue(0);
|
||||||
@ -103,7 +103,7 @@ export const SigningCard3D = ({
|
|||||||
clearTimeout(timeoutRef.current);
|
clearTimeout(timeoutRef.current);
|
||||||
|
|
||||||
// Revert the card back to the center position after the mouse stops moving.
|
// Revert the card back to the center position after the mouse stops moving.
|
||||||
timeoutRef.current = setTimeout(() => {
|
timeoutRef.current = window.setTimeout(() => {
|
||||||
void animate(cardX, 0, { duration: 2, ease: 'backInOut' });
|
void animate(cardX, 0, { duration: 2, ease: 'backInOut' });
|
||||||
void animate(cardY, 0, { duration: 2, ease: 'backInOut' });
|
void animate(cardY, 0, { duration: 2, ease: 'backInOut' });
|
||||||
|
|
||||||
@ -120,12 +120,15 @@ export const SigningCard3D = ({
|
|||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('mousemove', onMouseMove);
|
window.removeEventListener('mousemove', onMouseMove);
|
||||||
|
if (timeoutRef.current) {
|
||||||
|
window.clearTimeout(timeoutRef.current);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}, [onMouseMove]);
|
}, [onMouseMove]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn('relative w-full max-w-xs md:max-w-sm', className)}
|
className={cn('relative w-full max-w-sm md:max-w-md', className)}
|
||||||
style={{ perspective: 800 }}
|
style={{ perspective: 800 }}
|
||||||
>
|
>
|
||||||
<motion.div
|
<motion.div
|
||||||
|
|||||||
@ -400,25 +400,16 @@ export const AddFieldsFormPartial = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const onFieldCopy = useCallback(
|
const onFieldCopy = useCallback(
|
||||||
(event?: KeyboardEvent | null, options?: { duplicate?: boolean }) => {
|
(event?: KeyboardEvent | null, options?: { duplicate?: boolean; duplicateAll?: boolean }) => {
|
||||||
const { duplicate = false } = options ?? {};
|
const { duplicate = false, duplicateAll = false } = options ?? {};
|
||||||
|
|
||||||
if (lastActiveField) {
|
if (lastActiveField) {
|
||||||
event?.preventDefault();
|
event?.preventDefault();
|
||||||
|
|
||||||
if (!duplicate) {
|
if (duplicate) {
|
||||||
setFieldClipboard(lastActiveField);
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: 'Copied field',
|
|
||||||
description: 'Copied field to clipboard',
|
|
||||||
});
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const newField: TAddFieldsFormSchema['fields'][0] = {
|
const newField: TAddFieldsFormSchema['fields'][0] = {
|
||||||
...structuredClone(lastActiveField),
|
...structuredClone(lastActiveField),
|
||||||
|
nativeId: undefined,
|
||||||
formId: nanoid(12),
|
formId: nanoid(12),
|
||||||
signerEmail: selectedSigner?.email ?? lastActiveField.signerEmail,
|
signerEmail: selectedSigner?.email ?? lastActiveField.signerEmail,
|
||||||
pageX: lastActiveField.pageX + 3,
|
pageX: lastActiveField.pageX + 3,
|
||||||
@ -426,9 +417,43 @@ export const AddFieldsFormPartial = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
append(newField);
|
append(newField);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (duplicateAll) {
|
||||||
|
const pages = Array.from(document.querySelectorAll(PDF_VIEWER_PAGE_SELECTOR));
|
||||||
|
|
||||||
|
pages.forEach((_, index) => {
|
||||||
|
const pageNumber = index + 1;
|
||||||
|
|
||||||
|
if (pageNumber === lastActiveField.pageNumber) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newField: TAddFieldsFormSchema['fields'][0] = {
|
||||||
|
...structuredClone(lastActiveField),
|
||||||
|
nativeId: undefined,
|
||||||
|
formId: nanoid(12),
|
||||||
|
signerEmail: selectedSigner?.email ?? lastActiveField.signerEmail,
|
||||||
|
pageNumber,
|
||||||
|
};
|
||||||
|
|
||||||
|
append(newField);
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setFieldClipboard(lastActiveField);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Copied field',
|
||||||
|
description: 'Copied field to clipboard',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[append, lastActiveField, selectedSigner?.email, toast],
|
[append, lastActiveField, selectedSigner?.email, selectedSigner?.id, toast],
|
||||||
);
|
);
|
||||||
|
|
||||||
const onFieldPaste = useCallback(
|
const onFieldPaste = useCallback(
|
||||||
@ -641,6 +666,7 @@ export const AddFieldsFormPartial = ({
|
|||||||
onMove={(options) => onFieldMove(options, index)}
|
onMove={(options) => onFieldMove(options, index)}
|
||||||
onRemove={() => remove(index)}
|
onRemove={() => remove(index)}
|
||||||
onDuplicate={() => onFieldCopy(null, { duplicate: true })}
|
onDuplicate={() => onFieldCopy(null, { duplicate: true })}
|
||||||
|
onDuplicateAllPages={() => onFieldCopy(null, { duplicateAll: true })}
|
||||||
onAdvancedSettings={() => {
|
onAdvancedSettings={() => {
|
||||||
setCurrentField(field);
|
setCurrentField(field);
|
||||||
handleAdvancedSettings();
|
handleAdvancedSettings();
|
||||||
|
|||||||
@ -311,6 +311,7 @@ export const FieldAdvancedSettings = forwardRef<HTMLDivElement, FieldAdvancedSet
|
|||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
.otherwise(() => null)}
|
.otherwise(() => null)}
|
||||||
|
|
||||||
{errors.length > 0 && (
|
{errors.length > 0 && (
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<ul>
|
<ul>
|
||||||
@ -323,6 +324,7 @@ export const FieldAdvancedSettings = forwardRef<HTMLDivElement, FieldAdvancedSet
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</DocumentFlowFormContainerContent>
|
</DocumentFlowFormContainerContent>
|
||||||
|
|
||||||
<DocumentFlowFormContainerFooter className="mt-auto">
|
<DocumentFlowFormContainerFooter className="mt-auto">
|
||||||
<DocumentFlowFormContainerActions
|
<DocumentFlowFormContainerActions
|
||||||
goNextLabel={msg`Save`}
|
goNextLabel={msg`Save`}
|
||||||
|
|||||||
@ -1,7 +1,9 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import { msg } from '@lingui/core/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
import { FieldType } from '@prisma/client';
|
import { FieldType } from '@prisma/client';
|
||||||
import { CopyPlus, Settings2, Trash } from 'lucide-react';
|
import { CopyPlus, Settings2, SquareStack, Trash } from 'lucide-react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { Rnd } from 'react-rnd';
|
import { Rnd } from 'react-rnd';
|
||||||
|
|
||||||
@ -29,6 +31,7 @@ export type FieldItemProps = {
|
|||||||
onMove?: (_node: HTMLElement) => void;
|
onMove?: (_node: HTMLElement) => void;
|
||||||
onRemove?: () => void;
|
onRemove?: () => void;
|
||||||
onDuplicate?: () => void;
|
onDuplicate?: () => void;
|
||||||
|
onDuplicateAllPages?: () => void;
|
||||||
onAdvancedSettings?: () => void;
|
onAdvancedSettings?: () => void;
|
||||||
onFocus?: () => void;
|
onFocus?: () => void;
|
||||||
onBlur?: () => void;
|
onBlur?: () => void;
|
||||||
@ -55,15 +58,18 @@ export const FieldItem = ({
|
|||||||
onMove,
|
onMove,
|
||||||
onRemove,
|
onRemove,
|
||||||
onDuplicate,
|
onDuplicate,
|
||||||
|
onDuplicateAllPages,
|
||||||
|
onAdvancedSettings,
|
||||||
onFocus,
|
onFocus,
|
||||||
onBlur,
|
onBlur,
|
||||||
onAdvancedSettings,
|
|
||||||
recipientIndex = 0,
|
recipientIndex = 0,
|
||||||
hasErrors,
|
hasErrors,
|
||||||
active,
|
active,
|
||||||
onFieldActivate,
|
onFieldActivate,
|
||||||
onFieldDeactivate,
|
onFieldDeactivate,
|
||||||
}: FieldItemProps) => {
|
}: FieldItemProps) => {
|
||||||
|
const { _ } = useLingui();
|
||||||
|
|
||||||
const [coords, setCoords] = useState({
|
const [coords, setCoords] = useState({
|
||||||
pageX: 0,
|
pageX: 0,
|
||||||
pageY: 0,
|
pageY: 0,
|
||||||
@ -304,6 +310,7 @@ export const FieldItem = ({
|
|||||||
<div className="group flex items-center justify-evenly gap-x-1 rounded-md border bg-gray-900 p-0.5">
|
<div className="group flex items-center justify-evenly gap-x-1 rounded-md border bg-gray-900 p-0.5">
|
||||||
{advancedField && (
|
{advancedField && (
|
||||||
<button
|
<button
|
||||||
|
title={_(msg`Advanced settings`)}
|
||||||
className="rounded-sm p-1.5 text-gray-400 transition-colors hover:bg-white/10 hover:text-gray-100"
|
className="rounded-sm p-1.5 text-gray-400 transition-colors hover:bg-white/10 hover:text-gray-100"
|
||||||
onClick={onAdvancedSettings}
|
onClick={onAdvancedSettings}
|
||||||
onTouchEnd={onAdvancedSettings}
|
onTouchEnd={onAdvancedSettings}
|
||||||
@ -313,6 +320,7 @@ export const FieldItem = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
title={_(msg`Duplicate`)}
|
||||||
className="rounded-sm p-1.5 text-gray-400 transition-colors hover:bg-white/10 hover:text-gray-100"
|
className="rounded-sm p-1.5 text-gray-400 transition-colors hover:bg-white/10 hover:text-gray-100"
|
||||||
onClick={onDuplicate}
|
onClick={onDuplicate}
|
||||||
onTouchEnd={onDuplicate}
|
onTouchEnd={onDuplicate}
|
||||||
@ -321,6 +329,16 @@ export const FieldItem = ({
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
title={_(msg`Duplicate on all pages`)}
|
||||||
|
className="rounded-sm p-1.5 text-gray-400 transition-colors hover:bg-white/10 hover:text-gray-100"
|
||||||
|
onClick={onDuplicateAllPages}
|
||||||
|
onTouchEnd={onDuplicateAllPages}
|
||||||
|
>
|
||||||
|
<SquareStack className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
title={_(msg`Remove`)}
|
||||||
className="rounded-sm p-1.5 text-gray-400 transition-colors hover:bg-white/10 hover:text-gray-100"
|
className="rounded-sm p-1.5 text-gray-400 transition-colors hover:bg-white/10 hover:text-gray-100"
|
||||||
onClick={onRemove}
|
onClick={onRemove}
|
||||||
onTouchEnd={onRemove}
|
onTouchEnd={onRemove}
|
||||||
|
|||||||
83
packages/ui/primitives/split-button.tsx
Normal file
83
packages/ui/primitives/split-button.tsx
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
import { ChevronDown } from 'lucide-react';
|
||||||
|
|
||||||
|
import { cn } from '../lib/utils';
|
||||||
|
import { Button } from './button';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from './dropdown-menu';
|
||||||
|
|
||||||
|
const SplitButtonContext = React.createContext<{
|
||||||
|
variant?: React.ComponentProps<typeof Button>['variant'];
|
||||||
|
size?: React.ComponentProps<typeof Button>['size'];
|
||||||
|
}>({});
|
||||||
|
|
||||||
|
const SplitButton = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement> & {
|
||||||
|
variant?: React.ComponentProps<typeof Button>['variant'];
|
||||||
|
size?: React.ComponentProps<typeof Button>['size'];
|
||||||
|
}
|
||||||
|
>(({ className, children, variant = 'default', size = 'default', ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<SplitButtonContext.Provider value={{ variant, size }}>
|
||||||
|
<div ref={ref} className={cn('inline-flex', className)} {...props}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</SplitButtonContext.Provider>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
SplitButton.displayName = 'SplitButton';
|
||||||
|
|
||||||
|
const SplitButtonAction = React.forwardRef<
|
||||||
|
HTMLButtonElement,
|
||||||
|
React.ButtonHTMLAttributes<HTMLButtonElement>
|
||||||
|
>(({ className, children, ...props }, ref) => {
|
||||||
|
const { variant, size } = React.useContext(SplitButtonContext);
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
ref={ref}
|
||||||
|
variant={variant}
|
||||||
|
size={size}
|
||||||
|
className={cn('flex-1 rounded-r-none border-r-0', className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
SplitButtonAction.displayName = 'SplitButtonAction';
|
||||||
|
|
||||||
|
const SplitButtonDropdown = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ children, ...props }, ref) => {
|
||||||
|
const { variant, size } = React.useContext(SplitButtonContext);
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant={variant}
|
||||||
|
size={size}
|
||||||
|
className="rounded-l-none px-2 focus-visible:ring-offset-0"
|
||||||
|
>
|
||||||
|
<ChevronDown className="h-4 w-4" />
|
||||||
|
<span className="sr-only">More options</span>
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" {...props} ref={ref}>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
SplitButtonDropdown.displayName = 'SplitButtonDropdown';
|
||||||
|
|
||||||
|
const SplitButtonDropdownItem = DropdownMenuItem;
|
||||||
|
|
||||||
|
export { SplitButton, SplitButtonAction, SplitButtonDropdown, SplitButtonDropdownItem };
|
||||||
@ -139,25 +139,16 @@ export const AddTemplateFieldsFormPartial = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const onFieldCopy = useCallback(
|
const onFieldCopy = useCallback(
|
||||||
(event?: KeyboardEvent | null, options?: { duplicate?: boolean }) => {
|
(event?: KeyboardEvent | null, options?: { duplicate?: boolean; duplicateAll?: boolean }) => {
|
||||||
const { duplicate = false } = options ?? {};
|
const { duplicate = false, duplicateAll = false } = options ?? {};
|
||||||
|
|
||||||
if (lastActiveField) {
|
if (lastActiveField) {
|
||||||
event?.preventDefault();
|
event?.preventDefault();
|
||||||
|
|
||||||
if (!duplicate) {
|
if (duplicate) {
|
||||||
setFieldClipboard(lastActiveField);
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: 'Copied field',
|
|
||||||
description: 'Copied field to clipboard',
|
|
||||||
});
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const newField: TAddTemplateFieldsFormSchema['fields'][0] = {
|
const newField: TAddTemplateFieldsFormSchema['fields'][0] = {
|
||||||
...structuredClone(lastActiveField),
|
...structuredClone(lastActiveField),
|
||||||
|
nativeId: undefined,
|
||||||
formId: nanoid(12),
|
formId: nanoid(12),
|
||||||
signerEmail: selectedSigner?.email ?? lastActiveField.signerEmail,
|
signerEmail: selectedSigner?.email ?? lastActiveField.signerEmail,
|
||||||
signerId: selectedSigner?.id ?? lastActiveField.signerId,
|
signerId: selectedSigner?.id ?? lastActiveField.signerId,
|
||||||
@ -167,16 +158,45 @@ export const AddTemplateFieldsFormPartial = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
append(newField);
|
append(newField);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (duplicateAll) {
|
||||||
|
const pages = Array.from(document.querySelectorAll(PDF_VIEWER_PAGE_SELECTOR));
|
||||||
|
|
||||||
|
pages.forEach((_, index) => {
|
||||||
|
const pageNumber = index + 1;
|
||||||
|
|
||||||
|
if (pageNumber === lastActiveField.pageNumber) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newField: TAddTemplateFieldsFormSchema['fields'][0] = {
|
||||||
|
...structuredClone(lastActiveField),
|
||||||
|
nativeId: undefined,
|
||||||
|
formId: nanoid(12),
|
||||||
|
signerEmail: selectedSigner?.email ?? lastActiveField.signerEmail,
|
||||||
|
signerId: selectedSigner?.id ?? lastActiveField.signerId,
|
||||||
|
signerToken: selectedSigner?.token ?? lastActiveField.signerToken,
|
||||||
|
pageNumber,
|
||||||
|
};
|
||||||
|
|
||||||
|
append(newField);
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setFieldClipboard(lastActiveField);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Copied field',
|
||||||
|
description: 'Copied field to clipboard',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[
|
[append, lastActiveField, selectedSigner?.email, selectedSigner?.id, toast],
|
||||||
append,
|
|
||||||
lastActiveField,
|
|
||||||
selectedSigner?.email,
|
|
||||||
selectedSigner?.id,
|
|
||||||
selectedSigner?.token,
|
|
||||||
toast,
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const onFieldPaste = useCallback(
|
const onFieldPaste = useCallback(
|
||||||
@ -543,6 +563,7 @@ export const AddTemplateFieldsFormPartial = ({
|
|||||||
onMove={(options) => onFieldMove(options, index)}
|
onMove={(options) => onFieldMove(options, index)}
|
||||||
onRemove={() => remove(index)}
|
onRemove={() => remove(index)}
|
||||||
onDuplicate={() => onFieldCopy(null, { duplicate: true })}
|
onDuplicate={() => onFieldCopy(null, { duplicate: true })}
|
||||||
|
onDuplicateAllPages={() => onFieldCopy(null, { duplicateAll: true })}
|
||||||
onAdvancedSettings={() => {
|
onAdvancedSettings={() => {
|
||||||
setCurrentField(field);
|
setCurrentField(field);
|
||||||
handleAdvancedSettings();
|
handleAdvancedSettings();
|
||||||
|
|||||||
Reference in New Issue
Block a user