Merge branch 'main' into feat/allow-same-signer-email-multiple-times

This commit is contained in:
Catalin Pit
2025-01-28 12:40:45 +02:00
committed by GitHub
34 changed files with 1325 additions and 159 deletions

View File

@ -21,6 +21,7 @@ Documenso supports Webhooks and allows you to subscribe to the following events:
- `document.signed` - `document.signed`
- `document.completed` - `document.completed`
- `document.rejected` - `document.rejected`
- `document.cancelled`
## Create a webhook subscription ## Create a webhook subscription
@ -37,7 +38,7 @@ Clicking on the "**Create Webhook**" button opens a modal to create a new webhoo
To create a new webhook subscription, you need to provide the following information: To create a new webhook subscription, you need to provide the following information:
- Enter the webhook URL that will receive the event payload. - Enter the webhook URL that will receive the event payload.
- Select the event(s) you want to subscribe to: `document.created`, `document.sent`, `document.opened`, `document.signed`, `document.completed`, `document.rejected`. - Select the event(s) you want to subscribe to: `document.created`, `document.sent`, `document.opened`, `document.signed`, `document.completed`, `document.rejected`, `document.cancelled`.
- Optionally, you can provide a secret key that will be used to sign the payload. This key will be included in the `X-Documenso-Secret` header of the request. - Optionally, you can provide a secret key that will be used to sign the payload. This key will be included in the `X-Documenso-Secret` header of the request.
![A screenshot of the Create Webhook modal that shows the URL input field and the event checkboxes](/webhook-images/webhooks-page-create-webhook-modal.webp) ![A screenshot of the Create Webhook modal that shows the URL input field and the event checkboxes](/webhook-images/webhooks-page-create-webhook-modal.webp)
@ -528,6 +529,96 @@ Example payload for the `document.rejected` event:
} }
``` ```
Example payload for the `document.rejected` event:
```json
{
"event": "DOCUMENT_CANCELLED",
"payload": {
"id": 7,
"externalId": null,
"userId": 3,
"authOptions": null,
"formValues": null,
"visibility": "EVERYONE",
"title": "documenso.pdf",
"status": "PENDING",
"documentDataId": "cm6exvn93006hi02ru90a265a",
"createdAt": "2025-01-27T11:02:14.393Z",
"updatedAt": "2025-01-27T11:03:16.387Z",
"completedAt": null,
"deletedAt": null,
"teamId": null,
"templateId": null,
"source": "DOCUMENT",
"documentMeta": {
"id": "cm6exvn96006ji02rqvzjvwoy",
"subject": "",
"message": "",
"timezone": "Etc/UTC",
"password": null,
"dateFormat": "yyyy-MM-dd hh:mm a",
"redirectUrl": "",
"signingOrder": "PARALLEL",
"typedSignatureEnabled": true,
"language": "en",
"distributionMethod": "EMAIL",
"emailSettings": {
"documentDeleted": true,
"documentPending": true,
"recipientSigned": true,
"recipientRemoved": true,
"documentCompleted": true,
"ownerDocumentCompleted": true,
"recipientSigningRequest": true
}
},
"recipients": [
{
"id": 7,
"documentId": 7,
"templateId": null,
"email": "mybirihix@mailinator.com",
"name": "Zorita Baird",
"token": "XkKx1HCs6Znm2UBJA2j6o",
"documentDeletedAt": null,
"expired": null,
"signedAt": null,
"authOptions": { "accessAuth": null, "actionAuth": null },
"signingOrder": 1,
"rejectionReason": null,
"role": "SIGNER",
"readStatus": "NOT_OPENED",
"signingStatus": "NOT_SIGNED",
"sendStatus": "SENT"
}
],
"Recipient": [
{
"id": 7,
"documentId": 7,
"templateId": null,
"email": "signer@documenso.com",
"name": "Signer",
"token": "XkKx1HCs6Znm2UBJA2j6o",
"documentDeletedAt": null,
"expired": null,
"signedAt": null,
"authOptions": { "accessAuth": null, "actionAuth": null },
"signingOrder": 1,
"rejectionReason": null,
"role": "SIGNER",
"readStatus": "NOT_OPENED",
"signingStatus": "NOT_SIGNED",
"sendStatus": "SENT"
}
]
},
"createdAt": "2025-01-27T11:03:27.730Z",
"webhookEndpoint": "https://mywebhooksite.com/mywebhook"
}
```
## Availability ## Availability
Webhooks are available to individual users and teams. Webhooks are available to individual users and teams.

View File

@ -4,7 +4,7 @@ import { useEffect, useMemo, useState, useTransition } from 'react';
import { msg } from '@lingui/macro'; import { msg } from '@lingui/macro';
import { useLingui } from '@lingui/react'; import { useLingui } from '@lingui/react';
import { ChevronDownIcon as CaretSortIcon, Loader } from 'lucide-react'; import { ChevronDownIcon, ChevronUpIcon, ChevronsUpDown, Loader } from 'lucide-react';
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value'; import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params'; import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
@ -54,7 +54,15 @@ export const LeaderboardTable = ({
onClick={() => handleColumnSort('name')} onClick={() => handleColumnSort('name')}
> >
{_(msg`Name`)} {_(msg`Name`)}
<CaretSortIcon className="ml-2 h-4 w-4" /> {sortBy === 'name' ? (
sortOrder === 'asc' ? (
<ChevronUpIcon className="ml-2 h-4 w-4" />
) : (
<ChevronDownIcon className="ml-2 h-4 w-4" />
)
) : (
<ChevronsUpDown className="ml-2 h-4 w-4" />
)}
</div> </div>
), ),
accessorKey: 'name', accessorKey: 'name',
@ -80,7 +88,15 @@ export const LeaderboardTable = ({
onClick={() => handleColumnSort('signingVolume')} onClick={() => handleColumnSort('signingVolume')}
> >
{_(msg`Signing Volume`)} {_(msg`Signing Volume`)}
<CaretSortIcon className="ml-2 h-4 w-4" /> {sortBy === 'signingVolume' ? (
sortOrder === 'asc' ? (
<ChevronUpIcon className="ml-2 h-4 w-4" />
) : (
<ChevronDownIcon className="ml-2 h-4 w-4" />
)
) : (
<ChevronsUpDown className="ml-2 h-4 w-4" />
)}
</div> </div>
), ),
accessorKey: 'signingVolume', accessorKey: 'signingVolume',
@ -94,7 +110,15 @@ export const LeaderboardTable = ({
onClick={() => handleColumnSort('createdAt')} onClick={() => handleColumnSort('createdAt')}
> >
{_(msg`Created`)} {_(msg`Created`)}
<CaretSortIcon className="ml-2 h-4 w-4" /> {sortBy === 'createdAt' ? (
sortOrder === 'asc' ? (
<ChevronUpIcon className="ml-2 h-4 w-4" />
) : (
<ChevronDownIcon className="ml-2 h-4 w-4" />
)
) : (
<ChevronsUpDown className="ml-2 h-4 w-4" />
)}
</div> </div>
); );
}, },
@ -102,7 +126,7 @@ export const LeaderboardTable = ({
cell: ({ row }) => i18n.date(row.original.createdAt), cell: ({ row }) => i18n.date(row.original.createdAt),
}, },
] satisfies DataTableColumnDef<SigningVolume>[]; ] satisfies DataTableColumnDef<SigningVolume>[];
}, [sortOrder]); }, [sortOrder, sortBy]);
useEffect(() => { useEffect(() => {
startTransition(() => { startTransition(() => {
@ -133,6 +157,9 @@ export const LeaderboardTable = ({
const handleColumnSort = (column: 'name' | 'createdAt' | 'signingVolume') => { const handleColumnSort = (column: 'name' | 'createdAt' | 'signingVolume') => {
startTransition(() => { startTransition(() => {
updateSearchParams({ updateSearchParams({
search: debouncedSearchString,
page,
perPage,
sortBy: column, sortBy: column,
sortOrder: sortBy === column && sortOrder === 'asc' ? 'desc' : 'asc', sortOrder: sortBy === column && sortOrder === 'asc' ? 'desc' : 'asc',
}); });

View File

@ -14,6 +14,7 @@ import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
import { DocumentReadOnlyFields } from '~/components/document/document-read-only-fields'; import { DocumentReadOnlyFields } from '~/components/document/document-read-only-fields';
import { TemplateType } from '~/components/formatter/template-type'; import { TemplateType } from '~/components/formatter/template-type';
import { TemplateBulkSendDialog } from '~/components/templates/template-bulk-send-dialog';
import { DataTableActionDropdown } from '../data-table-action-dropdown'; import { DataTableActionDropdown } from '../data-table-action-dropdown';
import { TemplateDirectLinkBadge } from '../template-direct-link-badge'; import { TemplateDirectLinkBadge } from '../template-direct-link-badge';
@ -111,6 +112,8 @@ export const TemplatePageView = async ({ params, team }: TemplatePageViewProps)
<div className="mt-2 flex flex-row space-x-4 sm:mt-0 sm:self-end"> <div className="mt-2 flex flex-row space-x-4 sm:mt-0 sm:self-end">
<TemplateDirectLinkDialogWrapper template={template} /> <TemplateDirectLinkDialogWrapper template={template} />
<TemplateBulkSendDialog templateId={template.id} recipients={template.recipients} />
<Button className="w-full" asChild> <Button className="w-full" asChild>
<Link href={`${templateRootPath}/${template.id}/edit`}> <Link href={`${templateRootPath}/${template.id}/edit`}>
<LucideEdit className="mr-1.5 h-3.5 w-3.5" /> <LucideEdit className="mr-1.5 h-3.5 w-3.5" />

View File

@ -5,7 +5,7 @@ import { useState } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { Trans } from '@lingui/macro'; import { Trans } from '@lingui/macro';
import { Copy, Edit, MoreHorizontal, MoveRight, Share2Icon, Trash2 } from 'lucide-react'; import { Copy, Edit, MoreHorizontal, MoveRight, Share2Icon, Trash2, Upload } from 'lucide-react';
import { useSession } from 'next-auth/react'; import { useSession } from 'next-auth/react';
import type { Recipient, Template, TemplateDirectLink } from '@documenso/prisma/client'; import type { Recipient, Template, TemplateDirectLink } from '@documenso/prisma/client';
@ -17,6 +17,8 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@documenso/ui/primitives/dropdown-menu'; } from '@documenso/ui/primitives/dropdown-menu';
import { TemplateBulkSendDialog } from '~/components/templates/template-bulk-send-dialog';
import { DeleteTemplateDialog } from './delete-template-dialog'; import { DeleteTemplateDialog } from './delete-template-dialog';
import { DuplicateTemplateDialog } from './duplicate-template-dialog'; import { DuplicateTemplateDialog } from './duplicate-template-dialog';
import { MoveTemplateDialog } from './move-template-dialog'; import { MoveTemplateDialog } from './move-template-dialog';
@ -86,6 +88,17 @@ export const DataTableActionDropdown = ({
</DropdownMenuItem> </DropdownMenuItem>
)} )}
<TemplateBulkSendDialog
templateId={row.id}
recipients={row.recipients}
trigger={
<div className="hover:bg-accent hover:text-accent-foreground relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors">
<Upload className="mr-2 h-4 w-4" />
<Trans>Bulk Send via CSV</Trans>
</div>
}
/>
<DropdownMenuItem <DropdownMenuItem
disabled={!isOwner && !isTeamTemplate} disabled={!isOwner && !isTeamTemplate}
onClick={() => setDeleteDialogOpen(true)} onClick={() => setDeleteDialogOpen(true)}

View File

@ -16,6 +16,7 @@ import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones'
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc'; import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth'; import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import { ZDateFieldMeta } from '@documenso/lib/types/field-meta';
import type { Recipient } from '@documenso/prisma/client'; import type { Recipient } from '@documenso/prisma/client';
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature'; import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
@ -23,6 +24,7 @@ import type {
TRemovedSignedFieldWithTokenMutationSchema, TRemovedSignedFieldWithTokenMutationSchema,
TSignFieldWithTokenMutationSchema, TSignFieldWithTokenMutationSchema,
} from '@documenso/trpc/server/field-router/schema'; } from '@documenso/trpc/server/field-router/schema';
import { cn } from '@documenso/ui/lib/utils';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
import { SigningFieldContainer } from './signing-field-container'; import { SigningFieldContainer } from './signing-field-container';
@ -59,6 +61,9 @@ export const DateField = ({
isPending: isRemoveSignedFieldWithTokenLoading, isPending: isRemoveSignedFieldWithTokenLoading,
} = trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION); } = trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
const safeFieldMeta = ZDateFieldMeta.safeParse(field.fieldMeta);
const parsedFieldMeta = safeFieldMeta.success ? safeFieldMeta.data : null;
const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending; const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending;
const localDateString = convertToLocalSystemFormat(field.customText, dateFormat, timezone); const localDateString = convertToLocalSystemFormat(field.customText, dateFormat, timezone);
@ -150,9 +155,21 @@ export const DateField = ({
)} )}
{field.inserted && ( {field.inserted && (
<p className="text-muted-foreground dark:text-background/80 text-[clamp(0.425rem,25cqw,0.825rem)] duration-200"> <div className="flex h-full w-full items-center">
<p
className={cn(
'text-muted-foreground dark:text-background/80 w-full text-[clamp(0.425rem,25cqw,0.825rem)] duration-200',
{
'text-left': parsedFieldMeta?.textAlign === 'left',
'text-center':
!parsedFieldMeta?.textAlign || parsedFieldMeta?.textAlign === 'center',
'text-right': parsedFieldMeta?.textAlign === 'right',
},
)}
>
{localDateString} {localDateString}
</p> </p>
</div>
)} )}
</SigningFieldContainer> </SigningFieldContainer>
); );

View File

@ -11,6 +11,7 @@ import { Loader } from 'lucide-react';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc'; import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth'; import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import { ZEmailFieldMeta } from '@documenso/lib/types/field-meta';
import type { Recipient } from '@documenso/prisma/client'; import type { Recipient } from '@documenso/prisma/client';
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature'; import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
@ -18,6 +19,7 @@ import type {
TRemovedSignedFieldWithTokenMutationSchema, TRemovedSignedFieldWithTokenMutationSchema,
TSignFieldWithTokenMutationSchema, TSignFieldWithTokenMutationSchema,
} from '@documenso/trpc/server/field-router/schema'; } from '@documenso/trpc/server/field-router/schema';
import { cn } from '@documenso/ui/lib/utils';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
import { useRequiredSigningContext } from './provider'; import { useRequiredSigningContext } from './provider';
@ -48,6 +50,9 @@ export const EmailField = ({ field, recipient, onSignField, onUnsignField }: Ema
isPending: isRemoveSignedFieldWithTokenLoading, isPending: isRemoveSignedFieldWithTokenLoading,
} = trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION); } = trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
const safeFieldMeta = ZEmailFieldMeta.safeParse(field.fieldMeta);
const parsedFieldMeta = safeFieldMeta.success ? safeFieldMeta.data : null;
const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending; const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending;
const onSign = async (authOptions?: TRecipientActionAuth) => { const onSign = async (authOptions?: TRecipientActionAuth) => {
@ -128,9 +133,21 @@ export const EmailField = ({ field, recipient, onSignField, onUnsignField }: Ema
)} )}
{field.inserted && ( {field.inserted && (
<p className="text-muted-foreground dark:text-background/80 text-[clamp(0.425rem,25cqw,0.825rem)] duration-200"> <div className="flex h-full w-full items-center">
<p
className={cn(
'text-muted-foreground dark:text-background/80 w-full text-[clamp(0.425rem,25cqw,0.825rem)] duration-200',
{
'text-left': parsedFieldMeta?.textAlign === 'left',
'text-center':
!parsedFieldMeta?.textAlign || parsedFieldMeta?.textAlign === 'center',
'text-right': parsedFieldMeta?.textAlign === 'right',
},
)}
>
{field.customText} {field.customText}
</p> </p>
</div>
)} )}
</SigningFieldContainer> </SigningFieldContainer>
); );

View File

@ -11,6 +11,7 @@ import { Loader } from 'lucide-react';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc'; import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth'; import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import { ZNameFieldMeta } from '@documenso/lib/types/field-meta';
import { type Recipient } from '@documenso/prisma/client'; import { type Recipient } from '@documenso/prisma/client';
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature'; import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
@ -18,6 +19,7 @@ import type {
TRemovedSignedFieldWithTokenMutationSchema, TRemovedSignedFieldWithTokenMutationSchema,
TSignFieldWithTokenMutationSchema, TSignFieldWithTokenMutationSchema,
} from '@documenso/trpc/server/field-router/schema'; } from '@documenso/trpc/server/field-router/schema';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { Dialog, DialogContent, DialogFooter, DialogTitle } from '@documenso/ui/primitives/dialog'; import { Dialog, DialogContent, DialogFooter, DialogTitle } from '@documenso/ui/primitives/dialog';
import { Input } from '@documenso/ui/primitives/input'; import { Input } from '@documenso/ui/primitives/input';
@ -56,6 +58,9 @@ export const NameField = ({ field, recipient, onSignField, onUnsignField }: Name
isPending: isRemoveSignedFieldWithTokenLoading, isPending: isRemoveSignedFieldWithTokenLoading,
} = trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION); } = trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
const safeFieldMeta = ZNameFieldMeta.safeParse(field.fieldMeta);
const parsedFieldMeta = safeFieldMeta.success ? safeFieldMeta.data : null;
const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending; const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending;
const [showFullNameModal, setShowFullNameModal] = useState(false); const [showFullNameModal, setShowFullNameModal] = useState(false);
@ -172,9 +177,21 @@ export const NameField = ({ field, recipient, onSignField, onUnsignField }: Name
)} )}
{field.inserted && ( {field.inserted && (
<p className="text-muted-foreground dark:text-background/80 text-[clamp(0.425rem,25cqw,0.825rem)] duration-200"> <div className="flex h-full w-full items-center">
<p
className={cn(
'text-muted-foreground dark:text-background/80 w-full text-[clamp(0.425rem,25cqw,0.825rem)] duration-200',
{
'text-left': parsedFieldMeta?.textAlign === 'left',
'text-center':
!parsedFieldMeta?.textAlign || parsedFieldMeta?.textAlign === 'center',
'text-right': parsedFieldMeta?.textAlign === 'right',
},
)}
>
{field.customText} {field.customText}
</p> </p>
</div>
)} )}
<Dialog open={showFullNameModal} onOpenChange={setShowFullNameModal}> <Dialog open={showFullNameModal} onOpenChange={setShowFullNameModal}>

View File

@ -52,8 +52,19 @@ export const NumberField = ({ field, recipient, onSignField, onUnsignField }: Nu
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
const [showRadioModal, setShowRadioModal] = useState(false); const [showRadioModal, setShowRadioModal] = useState(false);
const parsedFieldMeta = field.fieldMeta ? ZNumberFieldMeta.parse(field.fieldMeta) : null; const { mutateAsync: signFieldWithToken, isPending: isSignFieldWithTokenLoading } =
const isReadOnly = parsedFieldMeta?.readOnly; trpc.field.signFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
const {
mutateAsync: removeSignedFieldWithToken,
isPending: isRemoveSignedFieldWithTokenLoading,
} = trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
const safeFieldMeta = ZNumberFieldMeta.safeParse(field.fieldMeta);
const parsedFieldMeta = safeFieldMeta.success ? safeFieldMeta.data : null;
const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending;
const defaultValue = parsedFieldMeta?.value; const defaultValue = parsedFieldMeta?.value;
const [localNumber, setLocalNumber] = useState( const [localNumber, setLocalNumber] = useState(
parsedFieldMeta?.value ? String(parsedFieldMeta.value) : '0', parsedFieldMeta?.value ? String(parsedFieldMeta.value) : '0',
@ -71,16 +82,6 @@ export const NumberField = ({ field, recipient, onSignField, onUnsignField }: Nu
const { executeActionAuthProcedure } = useRequiredDocumentAuthContext(); const { executeActionAuthProcedure } = useRequiredDocumentAuthContext();
const { mutateAsync: signFieldWithToken, isPending: isSignFieldWithTokenLoading } =
trpc.field.signFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
const {
mutateAsync: removeSignedFieldWithToken,
isPending: isRemoveSignedFieldWithTokenLoading,
} = trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending;
const handleNumberChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleNumberChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const text = e.target.value; const text = e.target.value;
setLocalNumber(text); setLocalNumber(text);
@ -208,7 +209,7 @@ export const NumberField = ({ field, recipient, onSignField, onUnsignField }: Nu
useEffect(() => { useEffect(() => {
if ( if (
(!field.inserted && defaultValue && localNumber) || (!field.inserted && defaultValue && localNumber) ||
(!field.inserted && isReadOnly && defaultValue) (!field.inserted && parsedFieldMeta?.readOnly && defaultValue)
) { ) {
void executeActionAuthProcedure({ void executeActionAuthProcedure({
onReauthFormSubmit: async (authOptions) => await onSign(authOptions), onReauthFormSubmit: async (authOptions) => await onSign(authOptions),
@ -260,9 +261,21 @@ export const NumberField = ({ field, recipient, onSignField, onUnsignField }: Nu
)} )}
{field.inserted && ( {field.inserted && (
<p className="text-muted-foreground dark:text-background/80 text-[clamp(0.425rem,25cqw,0.825rem)] duration-200"> <div className="flex h-full w-full items-center">
<p
className={cn(
'text-muted-foreground dark:text-background/80 w-full text-[clamp(0.425rem,25cqw,0.825rem)] duration-200',
{
'text-left': parsedFieldMeta?.textAlign === 'left',
'text-center':
!parsedFieldMeta?.textAlign || parsedFieldMeta?.textAlign === 'center',
'text-right': parsedFieldMeta?.textAlign === 'right',
},
)}
>
{field.customText} {field.customText}
</p> </p>
</div>
)} )}
<Dialog open={showRadioModal} onOpenChange={setShowRadioModal}> <Dialog open={showRadioModal} onOpenChange={setShowRadioModal}>

View File

@ -62,7 +62,8 @@ export const TextField = ({ field, recipient, onSignField, onUnsignField }: Text
isPending: isRemoveSignedFieldWithTokenLoading, isPending: isRemoveSignedFieldWithTokenLoading,
} = trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION); } = trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
const parsedFieldMeta = field.fieldMeta ? ZTextFieldMeta.parse(field.fieldMeta) : null; const safeFieldMeta = ZTextFieldMeta.safeParse(field.fieldMeta);
const parsedFieldMeta = safeFieldMeta.success ? safeFieldMeta.data : null;
const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending; const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending;
const shouldAutoSignField = const shouldAutoSignField =
@ -261,11 +262,23 @@ export const TextField = ({ field, recipient, onSignField, onUnsignField }: Text
)} )}
{field.inserted && ( {field.inserted && (
<p className="text-muted-foreground dark:text-background/80 flex items-center justify-center gap-x-1 text-[clamp(0.425rem,25cqw,0.825rem)] duration-200"> <div className="flex h-full w-full items-center">
<p
className={cn(
'text-muted-foreground dark:text-background/80 w-full text-[clamp(0.425rem,25cqw,0.825rem)] duration-200',
{
'text-left': parsedFieldMeta?.textAlign === 'left',
'text-center':
!parsedFieldMeta?.textAlign || parsedFieldMeta?.textAlign === 'center',
'text-right': parsedFieldMeta?.textAlign === 'right',
},
)}
>
{field.customText.length < 20 {field.customText.length < 20
? field.customText ? field.customText
: field.customText.substring(0, 15) + '...'} : field.customText.substring(0, 15) + '...'}
</p> </p>
</div>
)} )}
<Dialog open={showCustomTextModal} onOpenChange={setShowCustomTextModal}> <Dialog open={showCustomTextModal} onOpenChange={setShowCustomTextModal}>
@ -281,6 +294,10 @@ export const TextField = ({ field, recipient, onSignField, onUnsignField }: Text
className={cn('mt-2 w-full rounded-md', { className={cn('mt-2 w-full rounded-md', {
'border-2 border-red-300 ring-2 ring-red-200 ring-offset-2 ring-offset-red-200 focus-visible:border-red-400 focus-visible:ring-4 focus-visible:ring-red-200 focus-visible:ring-offset-2 focus-visible:ring-offset-red-200': 'border-2 border-red-300 ring-2 ring-red-200 ring-offset-2 ring-offset-red-200 focus-visible:border-red-400 focus-visible:ring-4 focus-visible:ring-red-200 focus-visible:ring-offset-2 focus-visible:ring-offset-red-200':
userInputHasErrors, userInputHasErrors,
'text-left': parsedFieldMeta?.textAlign === 'left',
'text-center':
!parsedFieldMeta?.textAlign || parsedFieldMeta?.textAlign === 'center',
'text-right': parsedFieldMeta?.textAlign === 'right',
})} })}
value={localText} value={localText}
onChange={handleTextChange} onChange={handleTextChange}

View File

@ -0,0 +1,275 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { File as FileIcon, Upload, X } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import { Checkbox } from '@documenso/ui/primitives/checkbox';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { Form, FormControl, FormField, FormItem } from '@documenso/ui/primitives/form/form';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useOptionalCurrentTeam } from '~/providers/team';
const ZBulkSendFormSchema = z.object({
file: z.instanceof(File),
sendImmediately: z.boolean().default(false),
});
type TBulkSendFormSchema = z.infer<typeof ZBulkSendFormSchema>;
export type TemplateBulkSendDialogProps = {
templateId: number;
recipients: Array<{ email: string; name?: string | null }>;
trigger?: React.ReactNode;
onSuccess?: () => void;
};
export const TemplateBulkSendDialog = ({
templateId,
recipients,
trigger,
onSuccess,
}: TemplateBulkSendDialogProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const team = useOptionalCurrentTeam();
const form = useForm<TBulkSendFormSchema>({
resolver: zodResolver(ZBulkSendFormSchema),
defaultValues: {
sendImmediately: false,
},
});
const { mutateAsync: uploadBulkSend } = trpc.template.uploadBulkSend.useMutation();
const onDownloadTemplate = () => {
const headers = recipients.flatMap((_, index) => [
`recipient_${index + 1}_email`,
`recipient_${index + 1}_name`,
]);
const exampleRow = recipients.flatMap((recipient) => [recipient.email, recipient.name || '']);
const csv = [headers.join(','), exampleRow.join(',')].join('\n');
const blob = new Blob([csv], { type: 'text/csv' });
const url = window.URL.createObjectURL(blob);
const a = Object.assign(document.createElement('a'), {
href: url,
download: 'template.csv',
});
a.click();
window.URL.revokeObjectURL(url);
};
const onSubmit = async (values: TBulkSendFormSchema) => {
try {
const csv = await values.file.text();
await uploadBulkSend({
templateId,
teamId: team?.id,
csv: csv,
sendImmediately: values.sendImmediately,
});
toast({
title: _(msg`Success`),
description: _(
msg`Your bulk send has been initiated. You will receive an email notification upon completion.`,
),
});
form.reset();
onSuccess?.();
} catch (err) {
console.error(err);
toast({
title: 'Error',
description: 'Failed to upload CSV. Please check the file format and try again.',
variant: 'destructive',
});
}
};
return (
<Dialog>
<DialogTrigger asChild>
{trigger ?? (
<Button>
<Upload className="mr-2 h-4 w-4" />
<Trans>Bulk Send via CSV</Trans>
</Button>
)}
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Bulk Send Template via CSV</Trans>
</DialogTitle>
<DialogDescription>
<Trans>
Upload a CSV file to create multiple documents from this template. Each row represents
one document with its recipient details.
</Trans>
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-y-4">
<div className="bg-muted/70 rounded-lg border p-4">
<h3 className="text-sm font-medium">
<Trans>CSV Structure</Trans>
</h3>
<p className="text-muted-foreground mt-1 text-sm">
<Trans>
For each recipient, provide their email (required) and name (optional) in separate
columns. Download the template CSV below for the correct format.
</Trans>
</p>
<p className="mt-4 text-sm">
<Trans>Current recipients:</Trans>
</p>
<ul className="text-muted-foreground mt-2 list-inside list-disc text-sm">
{recipients.map((recipient, index) => (
<li key={index}>
{recipient.name ? `${recipient.name} (${recipient.email})` : recipient.email}
</li>
))}
</ul>
</div>
<div className="flex flex-col gap-y-2">
<Button onClick={onDownloadTemplate} variant="outline" type="button">
<Trans>Download Template CSV</Trans>
</Button>
<p className="text-muted-foreground text-xs">
<Trans>Pre-formatted CSV template with example data.</Trans>
</p>
</div>
<FormField
control={form.control}
name="file"
render={({ field: { onChange, value }, fieldState: { error } }) => (
<FormItem>
<FormControl>
{!value ? (
<Button asChild variant="outline" className="w-full">
<label className="cursor-pointer">
<input
type="file"
accept=".csv"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) {
onChange(file);
}
}}
disabled={form.formState.isSubmitting}
/>
<Upload className="mr-2 h-4 w-4" />
<Trans>Upload CSV</Trans>
</label>
</Button>
) : (
<div className="flex h-10 items-center rounded-md border px-3">
<div className="flex flex-1 items-center gap-2">
<FileIcon className="text-muted-foreground h-4 w-4" />
<span className="flex-1 truncate text-sm">{value.name}</span>
</div>
<Button
type="button"
variant="link"
className="text-destructive hover:text-destructive p-0 text-xs"
onClick={() => onChange(null)}
disabled={form.formState.isSubmitting}
>
<X className="h-4 w-4" />
<span className="sr-only">
<Trans>Remove</Trans>
</span>
</Button>
</div>
)}
</FormControl>
{error && <p className="text-destructive text-sm">{error.message}</p>}
<p className="text-muted-foreground text-xs">
<Trans>
Maximum file size: 4MB. Maximum 100 rows per upload. Blank values will use
template defaults.
</Trans>
</p>
</FormItem>
)}
/>
<FormField
control={form.control}
name="sendImmediately"
render={({ field }) => (
<FormItem className="flex items-center space-x-2">
<FormControl>
<div className="flex items-center">
<Checkbox
id="send-immediately"
checked={field.value}
onCheckedChange={field.onChange}
/>
<label
htmlFor="send-immediately"
className="text-muted-foreground ml-2 flex items-center text-sm"
>
<Trans>Send documents to recipients immediately</Trans>
</label>
</div>
</FormControl>
</FormItem>
)}
/>
<DialogFooter className="mt-4">
<Button variant="secondary" onClick={() => form.reset()} type="button">
<Trans>Cancel</Trans>
</Button>
<Button type="submit" loading={form.formState.isSubmitting}>
<Trans>Upload and Process</Trans>
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
};

7
package-lock.json generated
View File

@ -13836,6 +13836,12 @@
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.2.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.2.tgz",
"integrity": "sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==" "integrity": "sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ=="
}, },
"node_modules/csv-parse": {
"version": "5.6.0",
"resolved": "https://registry.npmjs.org/csv-parse/-/csv-parse-5.6.0.tgz",
"integrity": "sha512-l3nz3euub2QMg5ouu5U09Ew9Wf6/wQ8I++ch1loQ0ljmzhmfZYrH9fflS22i/PQEvsPvxCwxgz5q7UB8K1JO4Q==",
"license": "MIT"
},
"node_modules/cytoscape": { "node_modules/cytoscape": {
"version": "3.28.1", "version": "3.28.1",
"resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.28.1.tgz", "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.28.1.tgz",
@ -35031,6 +35037,7 @@
"@trigger.dev/sdk": "^2.3.18", "@trigger.dev/sdk": "^2.3.18",
"@upstash/redis": "^1.20.6", "@upstash/redis": "^1.20.6",
"@vvo/tzdb": "^6.117.0", "@vvo/tzdb": "^6.117.0",
"csv-parse": "^5.6.0",
"inngest": "^3.19.13", "inngest": "^3.19.13",
"kysely": "^0.26.3", "kysely": "^0.26.3",
"luxon": "^3.4.0", "luxon": "^3.4.0",

View File

@ -2,7 +2,7 @@ import { useMemo } from 'react';
import { Trans } from '@lingui/macro'; import { Trans } from '@lingui/macro';
import { useLingui } from '@lingui/react'; import { useLingui } from '@lingui/react';
import { 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 { RecipientRole } from '@documenso/prisma/client';
@ -40,11 +40,9 @@ export const TemplateDocumentInvite = ({
const rejectDocumentLink = useMemo(() => { const rejectDocumentLink = useMemo(() => {
const url = new URL(signDocumentLink); const url = new URL(signDocumentLink);
url.searchParams.set('reject', 'true'); url.searchParams.set('reject', 'true');
return url.toString(); return url.toString();
}, []); }, [signDocumentLink]);
return ( return (
<> <>
@ -52,31 +50,32 @@ export const TemplateDocumentInvite = ({
<Section> <Section>
<Text className="text-primary mx-auto mb-0 max-w-[80%] text-center text-lg font-semibold"> <Text className="text-primary mx-auto mb-0 max-w-[80%] text-center text-lg font-semibold">
{selfSigner ? ( {match({ selfSigner, isTeamInvite, includeSenderDetails, teamName })
.with({ selfSigner: true }, () => (
<Trans> <Trans>
Please {_(actionVerb).toLowerCase()} your document Please {_(actionVerb).toLowerCase()} your document
<br />"{documentName}" <br />"{documentName}"
</Trans> </Trans>
) : isTeamInvite ? ( ))
<> .with({ isTeamInvite: true, includeSenderDetails: true, teamName: P.string }, () => (
{includeSenderDetails ? (
<Trans> <Trans>
{inviterName} on behalf of "{teamName}" has invited you to{' '} {inviterName} on behalf of "{teamName}" has invited you to{' '}
{_(actionVerb).toLowerCase()} {_(actionVerb).toLowerCase()}
<br />"{documentName}"
</Trans> </Trans>
) : ( ))
.with({ isTeamInvite: true, teamName: P.string }, () => (
<Trans> <Trans>
{teamName} has invited you to {_(actionVerb).toLowerCase()} {teamName} has invited you to {_(actionVerb).toLowerCase()}
</Trans>
)}
<br />"{documentName}" <br />"{documentName}"
</> </Trans>
) : ( ))
.otherwise(() => (
<Trans> <Trans>
{inviterName} has invited you to {_(actionVerb).toLowerCase()} {inviterName} has invited you to {_(actionVerb).toLowerCase()}
<br />"{documentName}" <br />"{documentName}"
</Trans> </Trans>
)} ))}
</Text> </Text>
<Text className="my-1 text-center text-base text-slate-400"> <Text className="my-1 text-center text-base text-slate-400">

View File

@ -0,0 +1,91 @@
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { Body, Container, Head, Html, Preview, Section, Text } from '../components';
import { TemplateFooter } from '../template-components/template-footer';
export interface BulkSendCompleteEmailProps {
userName: string;
templateName: string;
totalProcessed: number;
successCount: number;
failedCount: number;
errors: string[];
assetBaseUrl?: string;
}
export const BulkSendCompleteEmail = ({
userName,
templateName,
totalProcessed,
successCount,
failedCount,
errors,
}: BulkSendCompleteEmailProps) => {
const { _ } = useLingui();
return (
<Html>
<Head />
<Preview>{_(msg`Bulk send operation complete for template "${templateName}"`)}</Preview>
<Body className="mx-auto my-auto bg-white font-sans">
<Section>
<Container className="mx-auto mb-2 mt-8 max-w-xl rounded-lg border border-solid border-slate-200 p-4 backdrop-blur-sm">
<Section>
<Text className="text-sm">
<Trans>Hi {userName},</Trans>
</Text>
<Text className="text-sm">
<Trans>Your bulk send operation for template "{templateName}" has completed.</Trans>
</Text>
<Text className="text-lg font-semibold">
<Trans>Summary:</Trans>
</Text>
<ul className="my-2 ml-4 list-inside list-disc">
<li>
<Trans>Total rows processed: {totalProcessed}</Trans>
</li>
<li className="mt-1">
<Trans>Successfully created: {successCount}</Trans>
</li>
<li className="mt-1">
<Trans>Failed: {failedCount}</Trans>
</li>
</ul>
{failedCount > 0 && (
<Section className="mt-4">
<Text className="text-lg font-semibold">
<Trans>The following errors occurred:</Trans>
</Text>
<ul className="my-2 ml-4 list-inside list-disc">
{errors.map((error, index) => (
<li key={index} className="text-destructive mt-1 text-sm text-slate-400">
{error}
</li>
))}
</ul>
</Section>
)}
<Text className="text-sm">
<Trans>
You can view the created documents in your dashboard under the "Documents created
from template" section.
</Trans>
</Text>
</Section>
</Container>
<Container className="mx-auto max-w-xl">
<TemplateFooter isDocument={false} />
</Container>
</Section>
</Body>
</Html>
);
};

View File

@ -6,6 +6,7 @@ import { SEND_SIGNING_EMAIL_JOB_DEFINITION } from './definitions/emails/send-sig
import { SEND_TEAM_DELETED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-team-deleted-email'; import { SEND_TEAM_DELETED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-team-deleted-email';
import { SEND_TEAM_MEMBER_JOINED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-team-member-joined-email'; import { SEND_TEAM_MEMBER_JOINED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-team-member-joined-email';
import { SEND_TEAM_MEMBER_LEFT_EMAIL_JOB_DEFINITION } from './definitions/emails/send-team-member-left-email'; import { SEND_TEAM_MEMBER_LEFT_EMAIL_JOB_DEFINITION } from './definitions/emails/send-team-member-left-email';
import { BULK_SEND_TEMPLATE_JOB_DEFINITION } from './definitions/internal/bulk-send-template';
import { SEAL_DOCUMENT_JOB_DEFINITION } from './definitions/internal/seal-document'; import { SEAL_DOCUMENT_JOB_DEFINITION } from './definitions/internal/seal-document';
/** /**
@ -21,6 +22,7 @@ export const jobsClient = new JobClient([
SEAL_DOCUMENT_JOB_DEFINITION, SEAL_DOCUMENT_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,
BULK_SEND_TEMPLATE_JOB_DEFINITION,
] as const); ] as const);
export const jobs = jobsClient; export const jobs = jobsClient;

View File

@ -0,0 +1,39 @@
import { z } from 'zod';
import { ZRequestMetadataSchema } from '../../../universal/extract-request-metadata';
import { type JobDefinition } from '../../client/_internal/job';
const SEND_BULK_COMPLETE_EMAIL_JOB_DEFINITION_ID = 'send.bulk.complete.email';
const SEND_BULK_COMPLETE_EMAIL_JOB_DEFINITION_SCHEMA = z.object({
userId: z.number(),
templateId: z.number(),
templateName: z.string(),
totalProcessed: z.number(),
successCount: z.number(),
failedCount: z.number(),
errors: z.array(z.string()),
requestMetadata: ZRequestMetadataSchema.optional(),
});
export type TSendBulkCompleteEmailJobDefinition = z.infer<
typeof SEND_BULK_COMPLETE_EMAIL_JOB_DEFINITION_SCHEMA
>;
export const SEND_BULK_COMPLETE_EMAIL_JOB_DEFINITION = {
id: SEND_BULK_COMPLETE_EMAIL_JOB_DEFINITION_ID,
name: 'Send Bulk Complete Email',
version: '1.0.0',
trigger: {
name: SEND_BULK_COMPLETE_EMAIL_JOB_DEFINITION_ID,
schema: SEND_BULK_COMPLETE_EMAIL_JOB_DEFINITION_SCHEMA,
},
handler: async ({ payload, io }) => {
const handler = await import('./send-bulk-complete-email.handler');
await handler.run({ payload, io });
},
} as const satisfies JobDefinition<
typeof SEND_BULK_COMPLETE_EMAIL_JOB_DEFINITION_ID,
TSendBulkCompleteEmailJobDefinition
>;

View File

@ -0,0 +1,208 @@
import { createElement } from 'react';
import { msg } from '@lingui/macro';
import { parse } from 'csv-parse/sync';
import { z } from 'zod';
import { mailer } from '@documenso/email/mailer';
import { BulkSendCompleteEmail } from '@documenso/email/templates/bulk-send-complete';
import { sendDocument } from '@documenso/lib/server-only/document/send-document';
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 { prisma } from '@documenso/prisma';
import type { TeamGlobalSettings } from '@documenso/prisma/client';
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 { AppError } from '../../../errors/app-error';
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 { TBulkSendTemplateJobDefinition } from './bulk-send-template';
const ZRecipientRowSchema = z.object({
name: z.string().optional(),
email: z.union([
z.string().email({ message: 'Value must be a valid email or empty string' }),
z.string().max(0, { message: 'Value must be a valid email or empty string' }),
]),
});
export const run = async ({
payload,
io,
}: {
payload: TBulkSendTemplateJobDefinition;
io: JobRunIO;
}) => {
const { userId, teamId, templateId, csvContent, sendImmediately, requestMetadata } = payload;
const template = await getTemplateById({
id: templateId,
userId,
teamId,
});
if (!template) {
throw new Error('Template not found');
}
const rows = parse(csvContent, { columns: true, skip_empty_lines: true });
if (rows.length > 100) {
throw new Error('Maximum 100 rows allowed per upload');
}
const { recipients } = template;
// Validate CSV structure
const csvHeaders = Object.keys(rows[0]);
const requiredHeaders = recipients.map((_, index) => `recipient_${index + 1}_email`);
for (const header of requiredHeaders) {
if (!csvHeaders.includes(header)) {
throw new Error(`Missing required column: ${header}`);
}
}
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
},
select: {
email: true,
name: true,
},
});
const results = {
success: 0,
failed: 0,
errors: Array<string>(),
};
// Process each row
for (const [rowIndex, row] of rows.entries()) {
try {
for (const [recipientIndex] of recipients.entries()) {
const nameKey = `recipient_${recipientIndex + 1}_name`;
const emailKey = `recipient_${recipientIndex + 1}_email`;
const parsed = ZRecipientRowSchema.safeParse({
name: row[nameKey],
email: row[emailKey],
});
if (!parsed.success) {
throw new Error(
`Invalid recipient data provided for ${emailKey}, ${nameKey}: ${parsed.error.issues?.[0]?.message}`,
);
}
}
const document = await io.runTask(`create-document-${rowIndex}`, async () => {
return await createDocumentFromTemplate({
templateId: template.id,
userId,
teamId,
recipients: recipients.map((recipient, index) => {
return {
id: recipient.id,
email: row[`recipient_${index + 1}_email`] || recipient.email,
name: row[`recipient_${index + 1}_name`] || recipient.name,
role: recipient.role,
signingOrder: recipient.signingOrder,
};
}),
requestMetadata: {
source: 'app',
auth: 'session',
requestMetadata: requestMetadata || {},
},
});
});
if (sendImmediately) {
await io.runTask(`send-document-${rowIndex}`, async () => {
await sendDocument({
documentId: document.id,
userId,
teamId,
requestMetadata: {
source: 'app',
auth: 'session',
requestMetadata: requestMetadata || {},
},
}).catch((err) => {
console.error(err);
throw new AppError('DOCUMENT_SEND_FAILED');
});
});
}
results.success += 1;
} catch (error) {
results.failed += 1;
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
results.errors.push(`Row ${rowIndex + 1}: Was unable to be processed - ${errorMessage}`);
}
}
await io.runTask('send-completion-email', async () => {
const completionTemplate = createElement(BulkSendCompleteEmail, {
userName: user.name || user.email,
templateName: template.title,
totalProcessed: rows.length,
successCount: results.success,
failedCount: results.failed,
errors: results.errors,
assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL(),
});
let teamGlobalSettings: TeamGlobalSettings | undefined | null;
if (template.teamId) {
teamGlobalSettings = await prisma.teamGlobalSettings.findUnique({
where: {
teamId: template.teamId,
},
});
}
const branding = teamGlobalSettings
? teamGlobalSettingsToBranding(teamGlobalSettings)
: undefined;
const i18n = await getI18nInstance(teamGlobalSettings?.documentLanguage);
const [html, text] = await Promise.all([
renderEmailWithI18N(completionTemplate, {
lang: teamGlobalSettings?.documentLanguage,
branding,
}),
renderEmailWithI18N(completionTemplate, {
lang: teamGlobalSettings?.documentLanguage,
branding,
plainText: true,
}),
]);
await mailer.sendMail({
to: {
name: user.name || '',
address: user.email,
},
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
subject: i18n._(msg`Bulk Send Complete: ${template.title}`),
html,
text,
});
});
};

View File

@ -0,0 +1,37 @@
import { z } from 'zod';
import { ZRequestMetadataSchema } from '../../../universal/extract-request-metadata';
import { type JobDefinition } from '../../client/_internal/job';
const BULK_SEND_TEMPLATE_JOB_DEFINITION_ID = 'internal.bulk-send-template';
const BULK_SEND_TEMPLATE_JOB_DEFINITION_SCHEMA = z.object({
userId: z.number(),
teamId: z.number().optional(),
templateId: z.number(),
csvContent: z.string(),
sendImmediately: z.boolean(),
requestMetadata: ZRequestMetadataSchema.optional(),
});
export type TBulkSendTemplateJobDefinition = z.infer<
typeof BULK_SEND_TEMPLATE_JOB_DEFINITION_SCHEMA
>;
export const BULK_SEND_TEMPLATE_JOB_DEFINITION = {
id: BULK_SEND_TEMPLATE_JOB_DEFINITION_ID,
name: 'Bulk Send Template',
version: '1.0.0',
trigger: {
name: BULK_SEND_TEMPLATE_JOB_DEFINITION_ID,
schema: BULK_SEND_TEMPLATE_JOB_DEFINITION_SCHEMA,
},
handler: async ({ payload, io }) => {
const handler = await import('./bulk-send-template.handler');
await handler.run({ payload, io });
},
} as const satisfies JobDefinition<
typeof BULK_SEND_TEMPLATE_JOB_DEFINITION_ID,
TBulkSendTemplateJobDefinition
>;

View File

@ -40,6 +40,7 @@
"@trigger.dev/sdk": "^2.3.18", "@trigger.dev/sdk": "^2.3.18",
"@upstash/redis": "^1.20.6", "@upstash/redis": "^1.20.6",
"@vvo/tzdb": "^6.117.0", "@vvo/tzdb": "^6.117.0",
"csv-parse": "^5.6.0",
"inngest": "^3.19.13", "inngest": "^3.19.13",
"kysely": "^0.26.3", "kysely": "^0.26.3",
"luxon": "^3.4.0", "luxon": "^3.4.0",

View File

@ -1,5 +1,5 @@
import { prisma } from '@documenso/prisma'; import { kyselyPrisma, sql } from '@documenso/prisma';
import { DocumentStatus, Prisma } from '@documenso/prisma/client'; import { DocumentStatus, SubscriptionStatus } from '@documenso/prisma/client';
export type SigningVolume = { export type SigningVolume = {
id: number; id: number;
@ -24,92 +24,78 @@ export async function getSigningVolume({
sortBy = 'signingVolume', sortBy = 'signingVolume',
sortOrder = 'desc', sortOrder = 'desc',
}: GetSigningVolumeOptions) { }: GetSigningVolumeOptions) {
const whereClause = Prisma.validator<Prisma.SubscriptionWhereInput>()({ const offset = Math.max(page - 1, 0) * perPage;
status: 'ACTIVE',
OR: [
{
user: {
OR: [
{ name: { contains: search, mode: 'insensitive' } },
{ email: { contains: search, mode: 'insensitive' } },
],
},
},
{
team: {
name: { contains: search, mode: 'insensitive' },
},
},
],
});
const [subscriptions, totalCount] = await Promise.all([ let findQuery = kyselyPrisma.$kysely
prisma.subscription.findMany({ .selectFrom('Subscription as s')
where: whereClause, .leftJoin('User as u', 's.userId', 'u.id')
include: { .leftJoin('Team as t', 's.teamId', 't.id')
user: { .leftJoin('Document as ud', (join) =>
select: { join
name: true, .onRef('u.id', '=', 'ud.userId')
email: true, .on('ud.status', '=', sql.lit(DocumentStatus.COMPLETED))
documents: { .on('ud.deletedAt', 'is', null)
where: { .on('ud.teamId', 'is', null),
status: DocumentStatus.COMPLETED, )
deletedAt: null, .leftJoin('Document as td', (join) =>
teamId: null, join
}, .onRef('t.id', '=', 'td.teamId')
}, .on('td.status', '=', sql.lit(DocumentStatus.COMPLETED))
}, .on('td.deletedAt', 'is', null),
}, )
team: { // @ts-expect-error - Raw SQL enum casting not properly typed by Kysely
select: { .where(sql`s.status = ${SubscriptionStatus.ACTIVE}::"SubscriptionStatus"`)
name: true, .where((eb) =>
documents: { eb.or([
where: { eb('u.name', 'ilike', `%${search}%`),
status: DocumentStatus.COMPLETED, eb('u.email', 'ilike', `%${search}%`),
deletedAt: null, eb('t.name', 'ilike', `%${search}%`),
}, ]),
}, )
}, .select([
}, 's.id as id',
}, 's.createdAt as createdAt',
orderBy: 's.planId as planId',
sortBy === 'name' sql<string>`COALESCE(u.name, t.name, u.email, 'Unknown')`.as('name'),
? [{ user: { name: sortOrder } }, { team: { name: sortOrder } }, { createdAt: 'desc' }] sql<number>`COUNT(DISTINCT ud.id) + COUNT(DISTINCT td.id)`.as('signingVolume'),
: sortBy === 'createdAt' ])
? [{ createdAt: sortOrder }] .groupBy(['s.id', 'u.name', 't.name', 'u.email']);
: undefined,
skip: Math.max(page - 1, 0) * perPage,
take: perPage,
}),
prisma.subscription.count({
where: whereClause,
}),
]);
const leaderboardWithVolume: SigningVolume[] = subscriptions.map((subscription) => { switch (sortBy) {
const name = case 'name':
subscription.user?.name || subscription.team?.name || subscription.user?.email || 'Unknown'; findQuery = findQuery.orderBy('name', sortOrder);
const userSignedDocs = subscription.user?.documents?.length || 0; break;
const teamSignedDocs = subscription.team?.documents?.length || 0; case 'createdAt':
return { findQuery = findQuery.orderBy('createdAt', sortOrder);
id: subscription.id, break;
name, case 'signingVolume':
signingVolume: userSignedDocs + teamSignedDocs, findQuery = findQuery.orderBy('signingVolume', sortOrder);
createdAt: subscription.createdAt, break;
planId: subscription.planId, default:
}; findQuery = findQuery.orderBy('signingVolume', 'desc');
});
if (sortBy === 'signingVolume') {
leaderboardWithVolume.sort((a, b) => {
return sortOrder === 'desc'
? b.signingVolume - a.signingVolume
: a.signingVolume - b.signingVolume;
});
} }
findQuery = findQuery.limit(perPage).offset(offset);
const countQuery = kyselyPrisma.$kysely
.selectFrom('Subscription as s')
.leftJoin('User as u', 's.userId', 'u.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((eb) =>
eb.or([
eb('u.name', 'ilike', `%${search}%`),
eb('u.email', 'ilike', `%${search}%`),
eb('t.name', 'ilike', `%${search}%`),
]),
)
.select(({ fn }) => [fn.countAll().as('count')]);
const [results, [{ count }]] = await Promise.all([findQuery.execute(), countQuery.execute()]);
return { return {
leaderboard: leaderboardWithVolume, leaderboard: results,
totalPages: Math.ceil(totalCount / perPage), totalPages: Math.ceil(Number(count) / perPage),
}; };
} }

View File

@ -15,7 +15,7 @@ import type {
TeamGlobalSettings, TeamGlobalSettings,
User, User,
} from '@documenso/prisma/client'; } from '@documenso/prisma/client';
import { DocumentStatus, SendStatus } from '@documenso/prisma/client'; import { DocumentStatus, SendStatus, WebhookTriggerEvents } 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';
@ -23,10 +23,15 @@ import { FROM_ADDRESS, FROM_NAME } from '../../constants/email';
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 { extractDerivedDocumentEmailSettings } from '../../types/document-email'; import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
import {
ZWebhookDocumentSchema,
mapDocumentToWebhookDocumentPayload,
} from '../../types/webhook-payload';
import type { ApiRequestMetadata } from '../../universal/extract-request-metadata'; import type { ApiRequestMetadata } from '../../universal/extract-request-metadata';
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';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
export type DeleteDocumentOptions = { export type DeleteDocumentOptions = {
id: number; id: number;
@ -112,6 +117,13 @@ export const deleteDocument = async ({
}); });
} }
await triggerWebhook({
event: WebhookTriggerEvents.DOCUMENT_CANCELLED,
data: ZWebhookDocumentSchema.parse(mapDocumentToWebhookDocumentPayload(document)),
userId,
teamId,
});
// Return partial document for API v1 response. // Return partial document for API v1 response.
return { return {
id: document.id, id: document.id,

View File

@ -1,7 +1,7 @@
// https://github.com/Hopding/pdf-lib/issues/20#issuecomment-412852821 // https://github.com/Hopding/pdf-lib/issues/20#issuecomment-412852821
import fontkit from '@pdf-lib/fontkit'; import fontkit from '@pdf-lib/fontkit';
import type { PDFDocument } from 'pdf-lib'; import type { PDFDocument } from 'pdf-lib';
import { RotationTypes, degrees, radiansToDegrees } from 'pdf-lib'; import { RotationTypes, degrees, radiansToDegrees, rgb } from 'pdf-lib';
import { P, match } from 'ts-pattern'; import { P, match } from 'ts-pattern';
import { import {
@ -36,6 +36,9 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu
); );
const isSignatureField = isSignatureFieldType(field.type); const isSignatureField = isSignatureFieldType(field.type);
const isDebugMode =
// eslint-disable-next-line turbo/no-undeclared-env-vars
process.env.DEBUG_PDF_INSERT === '1' || process.env.DEBUG_PDF_INSERT === 'true';
pdf.registerFontkit(fontkit); pdf.registerFontkit(fontkit);
@ -83,6 +86,35 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu
const fieldX = pageWidth * (Number(field.positionX) / 100); const fieldX = pageWidth * (Number(field.positionX) / 100);
const fieldY = pageHeight * (Number(field.positionY) / 100); const fieldY = pageHeight * (Number(field.positionY) / 100);
// Draw debug box if debug mode is enabled
if (isDebugMode) {
let debugX = fieldX;
let debugY = pageHeight - fieldY - fieldHeight; // Invert Y for PDF coordinates
if (pageRotationInDegrees !== 0) {
const adjustedPosition = adjustPositionForRotation(
pageWidth,
pageHeight,
debugX,
debugY,
pageRotationInDegrees,
);
debugX = adjustedPosition.xPos;
debugY = adjustedPosition.yPos;
}
page.drawRectangle({
x: debugX,
y: debugY,
width: fieldWidth,
height: fieldHeight,
borderColor: rgb(1, 0, 0), // Red
borderWidth: 1,
rotate: degrees(pageRotationInDegrees),
});
}
const font = await pdf.embedFont( const font = await pdf.embedFont(
isSignatureField ? fontCaveat : fontNoto, isSignatureField ? fontCaveat : fontNoto,
isSignatureField ? { features: { calt: false } } : undefined, isSignatureField ? { features: { calt: false } } : undefined,
@ -278,6 +310,7 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu
const meta = Parser ? Parser.safeParse(field.fieldMeta) : null; const meta = Parser ? Parser.safeParse(field.fieldMeta) : null;
const customFontSize = meta?.success && meta.data.fontSize ? meta.data.fontSize : null; const customFontSize = meta?.success && meta.data.fontSize ? meta.data.fontSize : null;
const textAlign = meta?.success && meta.data.textAlign ? meta.data.textAlign : 'center';
const longestLineInTextForWidth = field.customText const longestLineInTextForWidth = field.customText
.split('\n') .split('\n')
.sort((a, b) => b.length - a.length)[0]; .sort((a, b) => b.length - a.length)[0];
@ -293,7 +326,17 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu
textWidth = font.widthOfTextAtSize(longestLineInTextForWidth, fontSize); textWidth = font.widthOfTextAtSize(longestLineInTextForWidth, fontSize);
let textX = fieldX + (fieldWidth - textWidth) / 2; // Add padding similar to web display (roughly 0.5rem equivalent in PDF units)
const padding = 8; // PDF points, roughly equivalent to 0.5rem
// Calculate X position based on text alignment with padding
let textX = fieldX + padding; // Left alignment starts after padding
if (textAlign === 'center') {
textX = fieldX + (fieldWidth - textWidth) / 2; // Center alignment ignores padding
} else if (textAlign === 'right') {
textX = fieldX + fieldWidth - textWidth - padding; // Right alignment respects right padding
}
let textY = fieldY + (fieldHeight - textHeight) / 2; let textY = fieldY + (fieldHeight - textHeight) / 2;
// Invert the Y axis since PDFs use a bottom-left coordinate system // Invert the Y axis since PDFs use a bottom-left coordinate system

View File

@ -95,7 +95,7 @@ export const createTeam = async ({
}); });
} }
await tx.team.create({ const team = await tx.team.create({
data: { data: {
name: teamName, name: teamName,
url: teamUrl, url: teamUrl,
@ -104,13 +104,23 @@ export const createTeam = async ({
members: { members: {
create: [ create: [
{ {
userId, userId: user.id,
role: TeamMemberRole.ADMIN, role: TeamMemberRole.ADMIN,
}, },
], ],
}, },
}, },
}); });
await tx.teamGlobalSettings.upsert({
where: {
teamId: team.id,
},
update: {},
create: {
teamId: team.id,
},
});
}); });
return { return {
@ -225,6 +235,16 @@ export const createTeamFromPendingTeam = async ({
}, },
}); });
await tx.teamGlobalSettings.upsert({
where: {
teamId: team.id,
},
update: {},
create: {
teamId: team.id,
},
});
await tx.subscription.upsert( await tx.subscription.upsert(
mapStripeSubscriptionToPrismaUpsertAction(subscription, undefined, team.id), mapStripeSubscriptionToPrismaUpsertAction(subscription, undefined, team.id),
); );

View File

@ -11,9 +11,14 @@ export const ZBaseFieldMeta = z.object({
export type TBaseFieldMeta = z.infer<typeof ZBaseFieldMeta>; export type TBaseFieldMeta = z.infer<typeof ZBaseFieldMeta>;
export const ZFieldTextAlignSchema = z.enum(['left', 'center', 'right']);
export type TFieldTextAlignSchema = z.infer<typeof ZFieldTextAlignSchema>;
export const ZInitialsFieldMeta = ZBaseFieldMeta.extend({ export const ZInitialsFieldMeta = ZBaseFieldMeta.extend({
type: z.literal('initials'), type: z.literal('initials'),
fontSize: z.number().min(8).max(96).optional(), fontSize: z.number().min(8).max(96).optional(),
textAlign: ZFieldTextAlignSchema.optional(),
}); });
export type TInitialsFieldMeta = z.infer<typeof ZInitialsFieldMeta>; export type TInitialsFieldMeta = z.infer<typeof ZInitialsFieldMeta>;
@ -21,6 +26,7 @@ export type TInitialsFieldMeta = z.infer<typeof ZInitialsFieldMeta>;
export const ZNameFieldMeta = ZBaseFieldMeta.extend({ export const ZNameFieldMeta = ZBaseFieldMeta.extend({
type: z.literal('name'), type: z.literal('name'),
fontSize: z.number().min(8).max(96).optional(), fontSize: z.number().min(8).max(96).optional(),
textAlign: ZFieldTextAlignSchema.optional(),
}); });
export type TNameFieldMeta = z.infer<typeof ZNameFieldMeta>; export type TNameFieldMeta = z.infer<typeof ZNameFieldMeta>;
@ -28,6 +34,7 @@ export type TNameFieldMeta = z.infer<typeof ZNameFieldMeta>;
export const ZEmailFieldMeta = ZBaseFieldMeta.extend({ export const ZEmailFieldMeta = ZBaseFieldMeta.extend({
type: z.literal('email'), type: z.literal('email'),
fontSize: z.number().min(8).max(96).optional(), fontSize: z.number().min(8).max(96).optional(),
textAlign: ZFieldTextAlignSchema.optional(),
}); });
export type TEmailFieldMeta = z.infer<typeof ZEmailFieldMeta>; export type TEmailFieldMeta = z.infer<typeof ZEmailFieldMeta>;
@ -35,6 +42,7 @@ export type TEmailFieldMeta = z.infer<typeof ZEmailFieldMeta>;
export const ZDateFieldMeta = ZBaseFieldMeta.extend({ export const ZDateFieldMeta = ZBaseFieldMeta.extend({
type: z.literal('date'), type: z.literal('date'),
fontSize: z.number().min(8).max(96).optional(), fontSize: z.number().min(8).max(96).optional(),
textAlign: ZFieldTextAlignSchema.optional(),
}); });
export type TDateFieldMeta = z.infer<typeof ZDateFieldMeta>; export type TDateFieldMeta = z.infer<typeof ZDateFieldMeta>;
@ -44,6 +52,7 @@ export const ZTextFieldMeta = ZBaseFieldMeta.extend({
text: z.string().optional(), text: z.string().optional(),
characterLimit: z.number().optional(), characterLimit: z.number().optional(),
fontSize: z.number().min(8).max(96).optional(), fontSize: z.number().min(8).max(96).optional(),
textAlign: ZFieldTextAlignSchema.optional(),
}); });
export type TTextFieldMeta = z.infer<typeof ZTextFieldMeta>; export type TTextFieldMeta = z.infer<typeof ZTextFieldMeta>;
@ -55,6 +64,7 @@ export const ZNumberFieldMeta = ZBaseFieldMeta.extend({
minValue: z.number().optional(), minValue: z.number().optional(),
maxValue: z.number().optional(), maxValue: z.number().optional(),
fontSize: z.number().min(8).max(96).optional(), fontSize: z.number().min(8).max(96).optional(),
textAlign: ZFieldTextAlignSchema.optional(),
}); });
export type TNumberFieldMeta = z.infer<typeof ZNumberFieldMeta>; export type TNumberFieldMeta = z.infer<typeof ZNumberFieldMeta>;

View File

@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "WebhookTriggerEvents" ADD VALUE 'DOCUMENT_CANCELLED';

View File

@ -175,6 +175,7 @@ enum WebhookTriggerEvents {
DOCUMENT_SIGNED DOCUMENT_SIGNED
DOCUMENT_COMPLETED DOCUMENT_COMPLETED
DOCUMENT_REJECTED DOCUMENT_REJECTED
DOCUMENT_CANCELLED
} }
model Webhook { model Webhook {

View File

@ -1,5 +1,8 @@
import { TRPCError } from '@trpc/server';
import { getServerLimits } from '@documenso/ee/server-only/limits/server'; import { getServerLimits } from '@documenso/ee/server-only/limits/server';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { jobs } from '@documenso/lib/jobs/client';
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 { sendDocument } from '@documenso/lib/server-only/document/send-document'; import { sendDocument } from '@documenso/lib/server-only/document/send-document';
import { import {
@ -25,6 +28,7 @@ import type { Document } from '@documenso/prisma/client';
import { ZGenericSuccessResponse, ZSuccessResponseSchema } from '../document-router/schema'; import { ZGenericSuccessResponse, ZSuccessResponseSchema } from '../document-router/schema';
import { authenticatedProcedure, maybeAuthenticatedProcedure, router } from '../trpc'; import { authenticatedProcedure, maybeAuthenticatedProcedure, router } from '../trpc';
import { import {
ZBulkSendTemplateMutationSchema,
ZCreateDocumentFromDirectTemplateRequestSchema, ZCreateDocumentFromDirectTemplateRequestSchema,
ZCreateDocumentFromTemplateRequestSchema, ZCreateDocumentFromTemplateRequestSchema,
ZCreateDocumentFromTemplateResponseSchema, ZCreateDocumentFromTemplateResponseSchema,
@ -414,4 +418,48 @@ export const templateRouter = router({
userId, userId,
}); });
}), }),
/**
* @private
*/
uploadBulkSend: authenticatedProcedure
.input(ZBulkSendTemplateMutationSchema)
.mutation(async ({ ctx, input }) => {
const { templateId, teamId, csv, sendImmediately } = input;
const { user } = ctx;
if (csv.length > 4 * 1024 * 1024) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'File size exceeds 4MB limit',
});
}
const template = await getTemplateById({
id: templateId,
teamId,
userId: user.id,
});
if (!template) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Template not found',
});
}
await jobs.triggerJob({
name: 'internal.bulk-send-template',
payload: {
userId: user.id,
teamId,
templateId,
csvContent: csv,
sendImmediately,
requestMetadata: ctx.metadata.requestMetadata,
},
});
return { success: true };
}),
}); });

View File

@ -183,6 +183,14 @@ export const ZMoveTemplateToTeamRequestSchema = z.object({
export const ZMoveTemplateToTeamResponseSchema = ZTemplateLiteSchema; export const ZMoveTemplateToTeamResponseSchema = ZTemplateLiteSchema;
export const ZBulkSendTemplateMutationSchema = z.object({
templateId: z.number(),
teamId: z.number().optional(),
csv: z.string().min(1),
sendImmediately: z.boolean(),
});
export type TCreateTemplateMutationSchema = z.infer<typeof ZCreateTemplateMutationSchema>; export type TCreateTemplateMutationSchema = z.infer<typeof ZCreateTemplateMutationSchema>;
export type TDuplicateTemplateMutationSchema = z.infer<typeof ZDuplicateTemplateMutationSchema>; export type TDuplicateTemplateMutationSchema = z.infer<typeof ZDuplicateTemplateMutationSchema>;
export type TDeleteTemplateMutationSchema = z.infer<typeof ZDeleteTemplateMutationSchema>; export type TDeleteTemplateMutationSchema = z.infer<typeof ZDeleteTemplateMutationSchema>;
export type TBulkSendTemplateMutationSchema = z.infer<typeof ZBulkSendTemplateMutationSchema>;

View File

@ -71,21 +71,25 @@ const getDefaultState = (fieldType: FieldType): FieldMeta => {
return { return {
type: 'initials', type: 'initials',
fontSize: 14, fontSize: 14,
textAlign: 'left',
}; };
case FieldType.NAME: case FieldType.NAME:
return { return {
type: 'name', type: 'name',
fontSize: 14, fontSize: 14,
textAlign: 'left',
}; };
case FieldType.EMAIL: case FieldType.EMAIL:
return { return {
type: 'email', type: 'email',
fontSize: 14, fontSize: 14,
textAlign: 'left',
}; };
case FieldType.DATE: case FieldType.DATE:
return { return {
type: 'date', type: 'date',
fontSize: 14, fontSize: 14,
textAlign: 'left',
}; };
case FieldType.TEXT: case FieldType.TEXT:
return { return {
@ -97,6 +101,7 @@ const getDefaultState = (fieldType: FieldType): FieldMeta => {
fontSize: 14, fontSize: 14,
required: false, required: false,
readOnly: false, readOnly: false,
textAlign: 'left',
}; };
case FieldType.NUMBER: case FieldType.NUMBER:
return { return {
@ -110,6 +115,7 @@ const getDefaultState = (fieldType: FieldType): FieldMeta => {
required: false, required: false,
readOnly: false, readOnly: false,
fontSize: 14, fontSize: 14,
textAlign: 'left',
}; };
case FieldType.RADIO: case FieldType.RADIO:
return { return {

View File

@ -5,6 +5,13 @@ import { validateFields as validateDateFields } from '@documenso/lib/advanced-fi
import { type TDateFieldMeta as DateFieldMeta } from '@documenso/lib/types/field-meta'; import { type TDateFieldMeta as DateFieldMeta } from '@documenso/lib/types/field-meta';
import { Input } from '@documenso/ui/primitives/input'; import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label'; import { Label } from '@documenso/ui/primitives/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
type DateFieldAdvancedSettingsProps = { type DateFieldAdvancedSettingsProps = {
fieldState: DateFieldMeta; fieldState: DateFieldMeta;
@ -66,6 +73,27 @@ export const DateFieldAdvancedSettings = ({
max={96} max={96}
/> />
</div> </div>
<div>
<Label>
<Trans>Text Align</Trans>
</Label>
<Select
value={fieldState.textAlign}
onValueChange={(value) => handleInput('textAlign', value)}
>
<SelectTrigger className="bg-background mt-2">
<SelectValue placeholder="Select text align" />
</SelectTrigger>
<SelectContent>
<SelectItem value="left">Left</SelectItem>
<SelectItem value="center">Center</SelectItem>
<SelectItem value="right">Right</SelectItem>
</SelectContent>
</Select>
</div>
</div> </div>
); );
}; };

View File

@ -5,6 +5,13 @@ import { validateFields as validateEmailFields } from '@documenso/lib/advanced-f
import { type TEmailFieldMeta as EmailFieldMeta } from '@documenso/lib/types/field-meta'; import { type TEmailFieldMeta as EmailFieldMeta } from '@documenso/lib/types/field-meta';
import { Input } from '@documenso/ui/primitives/input'; import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label'; import { Label } from '@documenso/ui/primitives/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
type EmailFieldAdvancedSettingsProps = { type EmailFieldAdvancedSettingsProps = {
fieldState: EmailFieldMeta; fieldState: EmailFieldMeta;
@ -48,6 +55,27 @@ export const EmailFieldAdvancedSettings = ({
max={96} max={96}
/> />
</div> </div>
<div>
<Label>
<Trans>Text Align</Trans>
</Label>
<Select
value={fieldState.textAlign}
onValueChange={(value) => handleInput('textAlign', value)}
>
<SelectTrigger className="bg-background mt-2">
<SelectValue placeholder="Select text align" />
</SelectTrigger>
<SelectContent>
<SelectItem value="left">Left</SelectItem>
<SelectItem value="center">Center</SelectItem>
<SelectItem value="right">Right</SelectItem>
</SelectContent>
</Select>
</div>
</div> </div>
); );
}; };

View File

@ -6,6 +6,8 @@ import { type TInitialsFieldMeta as InitialsFieldMeta } from '@documenso/lib/typ
import { Input } from '@documenso/ui/primitives/input'; import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label'; import { Label } from '@documenso/ui/primitives/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../../select';
type InitialsFieldAdvancedSettingsProps = { type InitialsFieldAdvancedSettingsProps = {
fieldState: InitialsFieldMeta; fieldState: InitialsFieldMeta;
handleFieldChange: (key: keyof InitialsFieldMeta, value: string | boolean) => void; handleFieldChange: (key: keyof InitialsFieldMeta, value: string | boolean) => void;
@ -48,6 +50,27 @@ export const InitialsFieldAdvancedSettings = ({
max={96} max={96}
/> />
</div> </div>
<div>
<Label>
<Trans>Text Align</Trans>
</Label>
<Select
value={fieldState.textAlign}
onValueChange={(value) => handleInput('textAlign', value)}
>
<SelectTrigger className="bg-background mt-2">
<SelectValue placeholder="Select text align" />
</SelectTrigger>
<SelectContent>
<SelectItem value="left">Left</SelectItem>
<SelectItem value="center">Center</SelectItem>
<SelectItem value="right">Right</SelectItem>
</SelectContent>
</Select>
</div>
</div> </div>
); );
}; };

View File

@ -5,6 +5,13 @@ import { validateFields as validateNameFields } from '@documenso/lib/advanced-fi
import { type TNameFieldMeta as NameFieldMeta } from '@documenso/lib/types/field-meta'; import { type TNameFieldMeta as NameFieldMeta } from '@documenso/lib/types/field-meta';
import { Input } from '@documenso/ui/primitives/input'; import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label'; import { Label } from '@documenso/ui/primitives/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
type NameFieldAdvancedSettingsProps = { type NameFieldAdvancedSettingsProps = {
fieldState: NameFieldMeta; fieldState: NameFieldMeta;
@ -48,6 +55,27 @@ export const NameFieldAdvancedSettings = ({
max={96} max={96}
/> />
</div> </div>
<div>
<Label>
<Trans>Text Align</Trans>
</Label>
<Select
value={fieldState.textAlign}
onValueChange={(value) => handleInput('textAlign', value)}
>
<SelectTrigger className="bg-background mt-2">
<SelectValue placeholder="Select text align" />
</SelectTrigger>
<SelectContent>
<SelectItem value="left">Left</SelectItem>
<SelectItem value="center">Center</SelectItem>
<SelectItem value="right">Right</SelectItem>
</SelectContent>
</Select>
</div>
</div> </div>
); );
}; };

View File

@ -38,12 +38,12 @@ export const NumberFieldAdvancedSettings = ({
const [showValidation, setShowValidation] = useState(false); const [showValidation, setShowValidation] = useState(false);
const handleInput = (field: keyof NumberFieldMeta, value: string | boolean) => { const handleInput = (field: keyof NumberFieldMeta, value: string | boolean) => {
const userValue = field === 'value' ? value : fieldState.value ?? 0; const userValue = field === 'value' ? value : (fieldState.value ?? 0);
const userMinValue = field === 'minValue' ? Number(value) : Number(fieldState.minValue ?? 0); const userMinValue = field === 'minValue' ? Number(value) : Number(fieldState.minValue ?? 0);
const userMaxValue = field === 'maxValue' ? Number(value) : Number(fieldState.maxValue ?? 0); const userMaxValue = field === 'maxValue' ? Number(value) : Number(fieldState.maxValue ?? 0);
const readOnly = field === 'readOnly' ? Boolean(value) : Boolean(fieldState.readOnly); const readOnly = field === 'readOnly' ? Boolean(value) : Boolean(fieldState.readOnly);
const required = field === 'required' ? Boolean(value) : Boolean(fieldState.required); const required = field === 'required' ? Boolean(value) : Boolean(fieldState.required);
const numberFormat = field === 'numberFormat' ? String(value) : fieldState.numberFormat ?? ''; const numberFormat = field === 'numberFormat' ? String(value) : (fieldState.numberFormat ?? '');
const fontSize = field === 'fontSize' ? Number(value) : Number(fieldState.fontSize ?? 14); const fontSize = field === 'fontSize' ? Number(value) : Number(fieldState.fontSize ?? 14);
const valueErrors = validateNumberField(String(userValue), { const valueErrors = validateNumberField(String(userValue), {
@ -135,6 +135,27 @@ export const NumberFieldAdvancedSettings = ({
/> />
</div> </div>
<div>
<Label>
<Trans>Text Align</Trans>
</Label>
<Select
value={fieldState.textAlign}
onValueChange={(value) => handleInput('textAlign', value)}
>
<SelectTrigger className="bg-background mt-2">
<SelectValue placeholder="Select text align" />
</SelectTrigger>
<SelectContent>
<SelectItem value="left">Left</SelectItem>
<SelectItem value="center">Center</SelectItem>
<SelectItem value="right">Right</SelectItem>
</SelectContent>
</Select>
</div>
<div className="mt-2 flex flex-col gap-4"> <div className="mt-2 flex flex-col gap-4">
<div className="flex flex-row items-center gap-2"> <div className="flex flex-row items-center gap-2">
<Switch <Switch

View File

@ -5,6 +5,13 @@ import { validateTextField } from '@documenso/lib/advanced-fields-validation/val
import { type TTextFieldMeta as TextFieldMeta } from '@documenso/lib/types/field-meta'; import { type TTextFieldMeta as TextFieldMeta } from '@documenso/lib/types/field-meta';
import { Input } from '@documenso/ui/primitives/input'; import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label'; import { Label } from '@documenso/ui/primitives/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
import { Switch } from '@documenso/ui/primitives/switch'; import { Switch } from '@documenso/ui/primitives/switch';
import { Textarea } from '@documenso/ui/primitives/textarea'; import { Textarea } from '@documenso/ui/primitives/textarea';
@ -22,7 +29,7 @@ export const TextFieldAdvancedSettings = ({
const { _ } = useLingui(); const { _ } = useLingui();
const handleInput = (field: keyof TextFieldMeta, value: string | boolean) => { const handleInput = (field: keyof TextFieldMeta, value: string | boolean) => {
const text = field === 'text' ? String(value) : fieldState.text ?? ''; const text = field === 'text' ? String(value) : (fieldState.text ?? '');
const limit = const limit =
field === 'characterLimit' ? Number(value) : Number(fieldState.characterLimit ?? 0); field === 'characterLimit' ? Number(value) : Number(fieldState.characterLimit ?? 0);
const fontSize = field === 'fontSize' ? Number(value) : Number(fieldState.fontSize ?? 14); const fontSize = field === 'fontSize' ? Number(value) : Number(fieldState.fontSize ?? 14);
@ -112,6 +119,27 @@ export const TextFieldAdvancedSettings = ({
/> />
</div> </div>
<div>
<Label>
<Trans>Text Align</Trans>
</Label>
<Select
value={fieldState.textAlign}
onValueChange={(value) => handleInput('textAlign', value)}
>
<SelectTrigger className="bg-background mt-2">
<SelectValue placeholder="Select text align" />
</SelectTrigger>
<SelectContent>
<SelectItem value="left">Left</SelectItem>
<SelectItem value="center">Center</SelectItem>
<SelectItem value="right">Right</SelectItem>
</SelectContent>
</Select>
</div>
<div className="mt-4 flex flex-col gap-4"> <div className="mt-4 flex flex-col gap-4">
<div className="flex flex-row items-center gap-2"> <div className="flex flex-row items-center gap-2">
<Switch <Switch