mirror of
https://github.com/documenso/documenso.git
synced 2025-11-14 08:42:12 +10:00
Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8d85d01c93 | |||
| cdb980bd4c | |||
| 413c3cbc6e | |||
| ff4ba71ef3 | |||
| 9bc825b308 | |||
| d7f7cbf27f | |||
| 97f7d77a34 | |||
| 6993c52b2a | |||
| 2f866c41b4 | |||
| 68c8eba2c3 | |||
| 7e4faef95f | |||
| bcef84787d | |||
| 70a3ac0525 | |||
| c6fb101a99 | |||
| 2984af769c | |||
| c40e802396 |
@ -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.
|
||||||
|
|
||||||

|

|
||||||
@ -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.
|
||||||
|
|||||||
@ -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',
|
||||||
});
|
});
|
||||||
|
|||||||
@ -0,0 +1,73 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { DateTime } from 'luxon';
|
||||||
|
import type { TooltipProps } from 'recharts';
|
||||||
|
import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
|
||||||
|
import type { NameType, ValueType } from 'recharts/types/component/DefaultTooltipContent';
|
||||||
|
|
||||||
|
import type { GetMonthlyActiveUsersResult } from '@documenso/lib/server-only/admin/get-users-stats';
|
||||||
|
|
||||||
|
export type MonthlyActiveUsersChartProps = {
|
||||||
|
className?: string;
|
||||||
|
title: string;
|
||||||
|
cummulative?: boolean;
|
||||||
|
data: GetMonthlyActiveUsersResult;
|
||||||
|
};
|
||||||
|
|
||||||
|
const CustomTooltip = ({ active, payload, label }: TooltipProps<ValueType, NameType>) => {
|
||||||
|
if (active && payload && payload.length) {
|
||||||
|
return (
|
||||||
|
<div className="z-100 w-60 space-y-1 rounded-md border border-solid bg-white p-2 px-3">
|
||||||
|
<p>{label}</p>
|
||||||
|
<p className="text-documenso">
|
||||||
|
{payload[0].name === 'cume_count' ? 'Cumulative MAU' : 'Monthly Active Users'}:{' '}
|
||||||
|
<span className="text-black">{Number(payload[0].value).toLocaleString('en-US')}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MonthlyActiveUsersChart = ({
|
||||||
|
className,
|
||||||
|
data,
|
||||||
|
title,
|
||||||
|
cummulative = false,
|
||||||
|
}: MonthlyActiveUsersChartProps) => {
|
||||||
|
const formattedData = [...data].reverse().map(({ month, count, cume_count }) => {
|
||||||
|
return {
|
||||||
|
month: DateTime.fromFormat(month, 'yyyy-MM').toFormat('MMM yyyy'),
|
||||||
|
count: Number(count),
|
||||||
|
cume_count: Number(cume_count),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
<div className="border-border flex flex-1 flex-col justify-center rounded-2xl border p-6 pl-2">
|
||||||
|
<div className="mb-6 flex px-4">
|
||||||
|
<h3 className="text-lg font-semibold">{title}</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ResponsiveContainer width="100%" height={400}>
|
||||||
|
<BarChart data={formattedData}>
|
||||||
|
<XAxis dataKey="month" />
|
||||||
|
<YAxis />
|
||||||
|
|
||||||
|
<Tooltip content={<CustomTooltip />} cursor={{ fill: 'hsl(var(--primary) / 10%)' }} />
|
||||||
|
|
||||||
|
<Bar
|
||||||
|
dataKey={cummulative ? 'cume_count' : 'count'}
|
||||||
|
fill="hsl(var(--primary))"
|
||||||
|
radius={[4, 4, 0, 0]}
|
||||||
|
maxBarSize={60}
|
||||||
|
label={cummulative ? 'Cumulative MAU' : 'Monthly Active Users'}
|
||||||
|
/>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -18,6 +18,7 @@ import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
|
|||||||
import { getDocumentStats } from '@documenso/lib/server-only/admin/get-documents-stats';
|
import { getDocumentStats } from '@documenso/lib/server-only/admin/get-documents-stats';
|
||||||
import { getRecipientsStats } from '@documenso/lib/server-only/admin/get-recipients-stats';
|
import { getRecipientsStats } from '@documenso/lib/server-only/admin/get-recipients-stats';
|
||||||
import {
|
import {
|
||||||
|
getMonthlyActiveUsers,
|
||||||
getUserWithSignedDocumentMonthlyGrowth,
|
getUserWithSignedDocumentMonthlyGrowth,
|
||||||
getUsersCount,
|
getUsersCount,
|
||||||
getUsersWithSubscriptionsCount,
|
getUsersWithSubscriptionsCount,
|
||||||
@ -26,6 +27,7 @@ import { getSignerConversionMonthly } from '@documenso/lib/server-only/user/get-
|
|||||||
|
|
||||||
import { CardMetric } from '~/components/(dashboard)/metric-card/metric-card';
|
import { CardMetric } from '~/components/(dashboard)/metric-card/metric-card';
|
||||||
|
|
||||||
|
import { MonthlyActiveUsersChart } from './monthly-active-users-chart';
|
||||||
import { SignerConversionChart } from './signer-conversion-chart';
|
import { SignerConversionChart } from './signer-conversion-chart';
|
||||||
import { UserWithDocumentChart } from './user-with-document';
|
import { UserWithDocumentChart } from './user-with-document';
|
||||||
|
|
||||||
@ -43,6 +45,7 @@ export default async function AdminStatsPage() {
|
|||||||
// userWithAtLeastOneDocumentPerMonth,
|
// userWithAtLeastOneDocumentPerMonth,
|
||||||
// userWithAtLeastOneDocumentSignedPerMonth,
|
// userWithAtLeastOneDocumentSignedPerMonth,
|
||||||
MONTHLY_USERS_SIGNED,
|
MONTHLY_USERS_SIGNED,
|
||||||
|
MONTHLY_ACTIVE_USERS,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
getUsersCount(),
|
getUsersCount(),
|
||||||
getUsersWithSubscriptionsCount(),
|
getUsersWithSubscriptionsCount(),
|
||||||
@ -52,6 +55,7 @@ export default async function AdminStatsPage() {
|
|||||||
// getUserWithAtLeastOneDocumentPerMonth(),
|
// getUserWithAtLeastOneDocumentPerMonth(),
|
||||||
// getUserWithAtLeastOneDocumentSignedPerMonth(),
|
// getUserWithAtLeastOneDocumentSignedPerMonth(),
|
||||||
getUserWithSignedDocumentMonthlyGrowth(),
|
getUserWithSignedDocumentMonthlyGrowth(),
|
||||||
|
getMonthlyActiveUsers(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -68,7 +72,6 @@ export default async function AdminStatsPage() {
|
|||||||
title={_(msg`Active Subscriptions`)}
|
title={_(msg`Active Subscriptions`)}
|
||||||
value={usersWithSubscriptionsCount}
|
value={usersWithSubscriptionsCount}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<CardMetric
|
<CardMetric
|
||||||
icon={FileCog}
|
icon={FileCog}
|
||||||
title={_(msg`App Version`)}
|
title={_(msg`App Version`)}
|
||||||
@ -132,6 +135,14 @@ export default async function AdminStatsPage() {
|
|||||||
<Trans>Charts</Trans>
|
<Trans>Charts</Trans>
|
||||||
</h3>
|
</h3>
|
||||||
<div className="mt-5 grid grid-cols-2 gap-8">
|
<div className="mt-5 grid grid-cols-2 gap-8">
|
||||||
|
<MonthlyActiveUsersChart title={_(msg`MAU (signed in)`)} data={MONTHLY_ACTIVE_USERS} />
|
||||||
|
|
||||||
|
<MonthlyActiveUsersChart
|
||||||
|
title={_(msg`Cumulative MAU (signed in)`)}
|
||||||
|
data={MONTHLY_ACTIVE_USERS}
|
||||||
|
cummulative
|
||||||
|
/>
|
||||||
|
|
||||||
<UserWithDocumentChart
|
<UserWithDocumentChart
|
||||||
data={MONTHLY_USERS_SIGNED}
|
data={MONTHLY_USERS_SIGNED}
|
||||||
title={_(msg`MAU (created document)`)}
|
title={_(msg`MAU (created document)`)}
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
|
|
||||||
import type { TooltipProps } from 'recharts';
|
import type { TooltipProps } from 'recharts';
|
||||||
|
import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
|
||||||
import type { NameType, ValueType } from 'recharts/types/component/DefaultTooltipContent';
|
import type { NameType, ValueType } from 'recharts/types/component/DefaultTooltipContent';
|
||||||
|
|
||||||
import type { GetUserWithDocumentMonthlyGrowth } from '@documenso/lib/server-only/admin/get-users-stats';
|
import type { GetUserWithDocumentMonthlyGrowth } from '@documenso/lib/server-only/admin/get-users-stats';
|
||||||
@ -23,7 +23,7 @@ const CustomTooltip = ({
|
|||||||
}: TooltipProps<ValueType, NameType> & { tooltip?: string }) => {
|
}: TooltipProps<ValueType, NameType> & { tooltip?: string }) => {
|
||||||
if (active && payload && payload.length) {
|
if (active && payload && payload.length) {
|
||||||
return (
|
return (
|
||||||
<div className="z-100 w-60 space-y-1 rounded-md border border-solid bg-white p-2 px-3">
|
<div className="z-100 w-60 space-y-1 rounded-md border border-solid bg-white p-2 px-3">
|
||||||
<p className="">{label}</p>
|
<p className="">{label}</p>
|
||||||
<p className="text-documenso">
|
<p className="text-documenso">
|
||||||
{`${tooltip} : `}
|
{`${tooltip} : `}
|
||||||
|
|||||||
@ -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" />
|
||||||
|
|||||||
@ -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)}
|
||||||
|
|||||||
@ -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">
|
||||||
{localDateString}
|
<p
|
||||||
</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}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</SigningFieldContainer>
|
</SigningFieldContainer>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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">
|
||||||
{field.customText}
|
<p
|
||||||
</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}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</SigningFieldContainer>
|
</SigningFieldContainer>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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">
|
||||||
{field.customText}
|
<p
|
||||||
</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}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Dialog open={showFullNameModal} onOpenChange={setShowFullNameModal}>
|
<Dialog open={showFullNameModal} onOpenChange={setShowFullNameModal}>
|
||||||
|
|||||||
@ -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">
|
||||||
{field.customText}
|
<p
|
||||||
</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}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Dialog open={showRadioModal} onOpenChange={setShowRadioModal}>
|
<Dialog open={showRadioModal} onOpenChange={setShowRadioModal}>
|
||||||
|
|||||||
@ -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">
|
||||||
{field.customText.length < 20
|
<p
|
||||||
? field.customText
|
className={cn(
|
||||||
: field.customText.substring(0, 15) + '...'}
|
'text-muted-foreground dark:text-background/80 w-full text-[clamp(0.425rem,25cqw,0.825rem)] duration-200',
|
||||||
</p>
|
{
|
||||||
|
'text-left': parsedFieldMeta?.textAlign === 'left',
|
||||||
|
'text-center':
|
||||||
|
!parsedFieldMeta?.textAlign || parsedFieldMeta?.textAlign === 'center',
|
||||||
|
'text-right': parsedFieldMeta?.textAlign === 'right',
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{field.customText.length < 20
|
||||||
|
? field.customText
|
||||||
|
: field.customText.substring(0, 15) + '...'}
|
||||||
|
</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}
|
||||||
|
|||||||
275
apps/web/src/components/templates/template-bulk-send-dialog.tsx
Normal file
275
apps/web/src/components/templates/template-bulk-send-dialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
22
package-lock.json
generated
22
package-lock.json
generated
@ -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",
|
||||||
@ -35715,6 +35722,21 @@
|
|||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"packages/trpc/node_modules/@next/swc-win32-ia32-msvc": {
|
||||||
|
"version": "14.2.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.6.tgz",
|
||||||
|
"integrity": "sha512-hNukAxq7hu4o5/UjPp5jqoBEtrpCbOmnUqZSKNJG8GrUVzfq0ucdhQFVrHcLRMvQcwqqDh1a5AJN9ORnNDpgBQ==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 })
|
||||||
<Trans>
|
.with({ selfSigner: true }, () => (
|
||||||
Please {_(actionVerb).toLowerCase()} your document
|
<Trans>
|
||||||
<br />"{documentName}"
|
Please {_(actionVerb).toLowerCase()} your document
|
||||||
</Trans>
|
<br />"{documentName}"
|
||||||
) : isTeamInvite ? (
|
</Trans>
|
||||||
<>
|
))
|
||||||
{includeSenderDetails ? (
|
.with({ isTeamInvite: true, includeSenderDetails: true, teamName: P.string }, () => (
|
||||||
<Trans>
|
<Trans>
|
||||||
{inviterName} on behalf of "{teamName}" has invited you to{' '}
|
{inviterName} on behalf of "{teamName}" has invited you to{' '}
|
||||||
{_(actionVerb).toLowerCase()}
|
{_(actionVerb).toLowerCase()}
|
||||||
</Trans>
|
<br />"{documentName}"
|
||||||
) : (
|
</Trans>
|
||||||
<Trans>
|
))
|
||||||
{teamName} has invited you to {_(actionVerb).toLowerCase()}
|
.with({ isTeamInvite: true, teamName: P.string }, () => (
|
||||||
</Trans>
|
<Trans>
|
||||||
)}
|
{teamName} has invited you to {_(actionVerb).toLowerCase()}
|
||||||
<br />"{documentName}"
|
<br />"{documentName}"
|
||||||
</>
|
</Trans>
|
||||||
) : (
|
))
|
||||||
<Trans>
|
.otherwise(() => (
|
||||||
{inviterName} has invited you to {_(actionVerb).toLowerCase()}
|
<Trans>
|
||||||
<br />"{documentName}"
|
{inviterName} has invited you to {_(actionVerb).toLowerCase()}
|
||||||
</Trans>
|
<br />"{documentName}"
|
||||||
)}
|
</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">
|
||||||
|
|||||||
91
packages/email/templates/bulk-send-complete.tsx
Normal file
91
packages/email/templates/bulk-send-complete.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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;
|
||||||
|
|||||||
@ -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
|
||||||
|
>;
|
||||||
@ -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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
37
packages/lib/jobs/definitions/internal/bulk-send-template.ts
Normal file
37
packages/lib/jobs/definitions/internal/bulk-send-template.ts
Normal 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
|
||||||
|
>;
|
||||||
@ -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",
|
||||||
|
|||||||
@ -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),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,11 @@
|
|||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
|
|
||||||
import { prisma } from '@documenso/prisma';
|
import { kyselyPrisma, prisma, sql } from '@documenso/prisma';
|
||||||
import { DocumentStatus, SubscriptionStatus } from '@documenso/prisma/client';
|
import {
|
||||||
|
DocumentStatus,
|
||||||
|
SubscriptionStatus,
|
||||||
|
UserSecurityAuditLogType,
|
||||||
|
} from '@documenso/prisma/client';
|
||||||
|
|
||||||
export const getUsersCount = async () => {
|
export const getUsersCount = async () => {
|
||||||
return await prisma.user.count();
|
return await prisma.user.count();
|
||||||
@ -80,3 +84,38 @@ export const getUserWithSignedDocumentMonthlyGrowth = async () => {
|
|||||||
signed_count: Number(row.signed_count),
|
signed_count: Number(row.signed_count),
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type GetMonthlyActiveUsersResult = Array<{
|
||||||
|
month: string;
|
||||||
|
count: number;
|
||||||
|
cume_count: number;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export const getMonthlyActiveUsers = async () => {
|
||||||
|
const qb = kyselyPrisma.$kysely
|
||||||
|
.selectFrom('UserSecurityAuditLog')
|
||||||
|
.select(({ fn }) => [
|
||||||
|
fn<Date>('DATE_TRUNC', [sql.lit('MONTH'), 'UserSecurityAuditLog.createdAt']).as('month'),
|
||||||
|
fn.count('userId').distinct().as('count'),
|
||||||
|
fn
|
||||||
|
.sum(fn.count('userId').distinct())
|
||||||
|
.over((ob) =>
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
ob.orderBy(fn('DATE_TRUNC', [sql.lit('MONTH'), 'UserSecurityAuditLog.createdAt']) as any),
|
||||||
|
)
|
||||||
|
.as('cume_count'),
|
||||||
|
])
|
||||||
|
// @ts-expect-error - Raw SQL enum casting not properly typed by Kysely
|
||||||
|
.where(sql`type = ${UserSecurityAuditLogType.SIGN_IN}::"UserSecurityAuditLogType"`)
|
||||||
|
.groupBy(({ fn }) => fn('DATE_TRUNC', [sql.lit('MONTH'), 'UserSecurityAuditLog.createdAt']))
|
||||||
|
.orderBy('month', 'desc')
|
||||||
|
.limit(12);
|
||||||
|
|
||||||
|
const result = await qb.execute();
|
||||||
|
|
||||||
|
return result.map((row) => ({
|
||||||
|
month: DateTime.fromJSDate(row.month).toFormat('yyyy-MM'),
|
||||||
|
count: Number(row.count),
|
||||||
|
cume_count: Number(row.cume_count),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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),
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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>;
|
||||||
|
|||||||
@ -0,0 +1,2 @@
|
|||||||
|
-- AlterEnum
|
||||||
|
ALTER TYPE "WebhookTriggerEvents" ADD VALUE 'DOCUMENT_CANCELLED';
|
||||||
@ -175,6 +175,7 @@ enum WebhookTriggerEvents {
|
|||||||
DOCUMENT_SIGNED
|
DOCUMENT_SIGNED
|
||||||
DOCUMENT_COMPLETED
|
DOCUMENT_COMPLETED
|
||||||
DOCUMENT_REJECTED
|
DOCUMENT_REJECTED
|
||||||
|
DOCUMENT_CANCELLED
|
||||||
}
|
}
|
||||||
|
|
||||||
model Webhook {
|
model Webhook {
|
||||||
|
|||||||
@ -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 };
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@ -188,6 +188,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>;
|
||||||
|
|||||||
@ -13,8 +13,14 @@ import { SUPPORTED_LANGUAGES } from '@documenso/lib/constants/i18n';
|
|||||||
import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones';
|
import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones';
|
||||||
import type { TDocument } from '@documenso/lib/types/document';
|
import type { TDocument } from '@documenso/lib/types/document';
|
||||||
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
|
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
|
||||||
import { DocumentVisibility, TeamMemberRole } from '@documenso/prisma/client';
|
import {
|
||||||
import { DocumentStatus, type Field, type Recipient, SendStatus } from '@documenso/prisma/client';
|
DocumentStatus,
|
||||||
|
DocumentVisibility,
|
||||||
|
type Field,
|
||||||
|
type Recipient,
|
||||||
|
SendStatus,
|
||||||
|
TeamMemberRole,
|
||||||
|
} from '@documenso/prisma/client';
|
||||||
import {
|
import {
|
||||||
DocumentGlobalAuthAccessSelect,
|
DocumentGlobalAuthAccessSelect,
|
||||||
DocumentGlobalAuthAccessTooltip,
|
DocumentGlobalAuthAccessTooltip,
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user