Compare commits

..
Author SHA1 Message Date
ephraimduncan 5388f202b5 Merge branch 'main' into feat/webhooks
# Conflicts:
#	apps/documentation/pages/developers/webhooks.mdx
#	packages/lib/server-only/document/resend-document.ts
#	packages/trpc/server/document-router/download-document.ts
2026-02-10 18:26:50 +00:00
Catalin PitandGitHub f1c91c4951 fix: bulk actions improvements (#2440) 2026-02-10 20:13:03 +11:00
Lucas SmithandGitHub a5ef1d23e6 feat: add team memberships section to admin user detail page (#2457) 2026-02-09 17:35:22 +11:00
github-actions[bot]andGitHub d91414697d chore: extract translations (#2429) 2026-02-09 17:30:46 +11:00
KonradandGitHub e222a872d2 fix(i18n): rewrite audit log messages to support correct grammar (#2455) 2026-02-09 13:20:12 +11:00
e3b0087be6 feat: create plain customer (#2442)
Co-authored-by: Catalin Pit <catalinpit@gmail.com>
2026-02-09 11:24:45 +11:00
KonradandGitHub da89ce7c9a fix(i18n): add localization context to dialog messages (#2452) 2026-02-09 10:52:50 +11:00
KonradandGitHub b762561f11 chore(i18n): add context to ambiguous message (#2454) 2026-02-09 10:52:00 +11:00
Ephraim DuncanandGitHub 3bcd023b00 Merge branch 'main' into feat/webhooks 2025-11-03 04:29:36 +00:00
Ephraim Atta-Duncan f6f66272b2 docs: document additional webhook events and payloads 2025-11-01 09:58:07 +00:00
Ephraim Atta-Duncan 776cd3aa8b feat: add more webhook events 2025-11-01 00:44:07 +00:00
41 changed files with 6897 additions and 2228 deletions
@@ -22,6 +22,15 @@ Documenso supports Webhooks and allows you to subscribe to the following events:
- `document.completed`
- `document.rejected`
- `document.cancelled`
- `document.viewed`
- `document.recipient.completed`
- `document.downloaded`
- `document.reminder.sent`
- `template.created`
- `template.updated`
- `template.deleted`
- `template.used`
- `recipient.authentication.failed`
## Create a webhook subscription
@@ -38,7 +47,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:
- 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`, `document.cancelled`.
- Select the event(s) you want to subscribe to: `document.created`, `document.sent`, `document.opened`, `document.signed`, `document.completed`, `document.rejected`, `document.cancelled`, `document.viewed`, `document.recipient.completed`, `document.downloaded`, `document.reminder.sent`, `template.created`, `template.updated`, `template.deleted`, `template.used`, `recipient.authentication.failed`.
- Optionally, you can provide a secret key that will be used to sign the payload. This key will be included in the `X-Documenso-Secret` header of the request.
![A screenshot of the Create Webhook modal that shows the URL input field and the event checkboxes](/webhook-images/create-webhook-dialog.webp)
@@ -634,6 +643,591 @@ Example payload for the `document.cancelled` event:
}
```
Example payload for the `document.viewed` event:
```json
{
"event": "DOCUMENT_VIEWED",
"payload": {
"id": 10,
"externalId": null,
"userId": 1,
"authOptions": null,
"formValues": null,
"visibility": "EVERYONE",
"title": "documenso.pdf",
"status": "PENDING",
"documentDataId": "hs8qz1ktr9204jn7mg6c5dxy0",
"createdAt": "2024-04-22T11:44:43.341Z",
"updatedAt": "2024-04-22T11:48:07.569Z",
"completedAt": null,
"deletedAt": null,
"teamId": null,
"templateId": null,
"source": "DOCUMENT",
"documentMeta": {
"id": "doc_meta_123",
"subject": "Please sign this document",
"message": "Hello, please review and sign this document.",
"timezone": "UTC",
"password": null,
"dateFormat": "MM/DD/YYYY",
"redirectUrl": null,
"signingOrder": "PARALLEL",
"typedSignatureEnabled": true,
"language": "en",
"distributionMethod": "EMAIL",
"emailSettings": null
},
"Recipient": [
{
"id": 52,
"documentId": 10,
"templateId": null,
"email": "signer@documenso.com",
"name": "John Doe",
"token": "vbT8hi3jKQmrFP_LN1WcS",
"documentDeletedAt": null,
"expired": null,
"signedAt": null,
"authOptions": null,
"signingOrder": 1,
"rejectionReason": null,
"role": "SIGNER",
"readStatus": "OPENED",
"signingStatus": "NOT_SIGNED",
"sendStatus": "SENT"
}
]
},
"createdAt": "2024-04-22T11:50:26.174Z",
"webhookEndpoint": "https://mywebhooksite.com/mywebhook"
}
```
Example payload for the `document.recipient.completed` event:
```json
{
"event": "DOCUMENT_RECIPIENT_COMPLETED",
"payload": {
"id": 10,
"externalId": null,
"userId": 1,
"authOptions": null,
"formValues": null,
"visibility": "EVERYONE",
"title": "documenso.pdf",
"status": "PENDING",
"documentDataId": "hs8qz1ktr9204jn7mg6c5dxy0",
"createdAt": "2024-04-22T11:44:43.341Z",
"updatedAt": "2024-04-22T11:51:10.055Z",
"completedAt": null,
"deletedAt": null,
"teamId": null,
"templateId": null,
"source": "DOCUMENT",
"documentMeta": {
"id": "doc_meta_123",
"subject": "Please sign this document",
"message": "Hello, please review and sign this document.",
"timezone": "UTC",
"password": null,
"dateFormat": "MM/DD/YYYY",
"redirectUrl": null,
"signingOrder": "PARALLEL",
"typedSignatureEnabled": true,
"language": "en",
"distributionMethod": "EMAIL",
"emailSettings": null
},
"Recipient": [
{
"id": 50,
"documentId": 10,
"templateId": null,
"email": "signer1@documenso.com",
"name": "Signer 1",
"token": "vbT8hi3jKQmrFP_LN1WcS",
"documentDeletedAt": null,
"expired": null,
"signedAt": "2024-04-22T11:51:10.055Z",
"authOptions": {
"accessAuth": null,
"actionAuth": null
},
"signingOrder": 1,
"rejectionReason": null,
"role": "SIGNER",
"readStatus": "OPENED",
"signingStatus": "SIGNED",
"sendStatus": "SENT"
},
{
"id": 51,
"documentId": 10,
"templateId": null,
"email": "signer2@documenso.com",
"name": "Signer 2",
"token": "HkrptwS42ZBXdRKj1TyUo",
"documentDeletedAt": null,
"expired": null,
"signedAt": null,
"authOptions": null,
"signingOrder": 2,
"rejectionReason": null,
"role": "SIGNER",
"readStatus": "NOT_OPENED",
"signingStatus": "NOT_SIGNED",
"sendStatus": "SENT"
}
]
},
"createdAt": "2024-04-22T11:51:10.577Z",
"webhookEndpoint": "https://mywebhooksite.com/mywebhook"
}
```
Example payload for the `document.downloaded` event:
```json
{
"event": "DOCUMENT_DOWNLOADED",
"payload": {
"id": 10,
"externalId": null,
"userId": 1,
"authOptions": null,
"formValues": null,
"visibility": "EVERYONE",
"title": "documenso.pdf",
"status": "COMPLETED",
"documentDataId": "hs8qz1ktr9204jn7mg6c5dxy0",
"createdAt": "2024-04-22T11:44:43.341Z",
"updatedAt": "2024-04-22T11:52:05.708Z",
"completedAt": "2024-04-22T11:52:05.707Z",
"deletedAt": null,
"teamId": null,
"templateId": null,
"source": "DOCUMENT",
"documentMeta": {
"id": "doc_meta_123",
"subject": "Please sign this document",
"message": "Hello, please review and sign this document.",
"timezone": "UTC",
"password": null,
"dateFormat": "MM/DD/YYYY",
"redirectUrl": null,
"signingOrder": "PARALLEL",
"typedSignatureEnabled": true,
"language": "en",
"distributionMethod": "EMAIL",
"emailSettings": null
},
"Recipient": [
{
"id": 51,
"documentId": 10,
"templateId": null,
"email": "signer@documenso.com",
"name": "Signer",
"token": "HkrptwS42ZBXdRKj1TyUo",
"documentDeletedAt": null,
"expired": null,
"signedAt": "2024-04-22T11:52:05.688Z",
"authOptions": {
"accessAuth": null,
"actionAuth": null
},
"signingOrder": 1,
"rejectionReason": null,
"role": "SIGNER",
"readStatus": "OPENED",
"signingStatus": "SIGNED",
"sendStatus": "SENT"
}
]
},
"createdAt": "2024-04-22T11:53:18.577Z",
"webhookEndpoint": "https://mywebhooksite.com/mywebhook"
}
```
Example payload for the `document.reminder.sent` event:
```json
{
"event": "DOCUMENT_REMINDER_SENT",
"payload": {
"id": 10,
"externalId": null,
"userId": 1,
"authOptions": null,
"formValues": null,
"visibility": "EVERYONE",
"title": "documenso.pdf",
"status": "PENDING",
"documentDataId": "hs8qz1ktr9204jn7mg6c5dxy0",
"createdAt": "2024-04-22T11:44:43.341Z",
"updatedAt": "2024-04-22T11:48:07.569Z",
"completedAt": null,
"deletedAt": null,
"teamId": null,
"templateId": null,
"source": "DOCUMENT",
"documentMeta": {
"id": "doc_meta_123",
"subject": "Please sign this document",
"message": "Hello, please review and sign this document.",
"timezone": "UTC",
"password": null,
"dateFormat": "MM/DD/YYYY",
"redirectUrl": null,
"signingOrder": "PARALLEL",
"typedSignatureEnabled": true,
"language": "en",
"distributionMethod": "EMAIL",
"emailSettings": null
},
"Recipient": [
{
"id": 52,
"documentId": 10,
"templateId": null,
"email": "signer@documenso.com",
"name": "Signer",
"token": "vbT8hi3jKQmrFP_LN1WcS",
"documentDeletedAt": null,
"expired": null,
"signedAt": null,
"authOptions": null,
"signingOrder": 1,
"rejectionReason": null,
"role": "SIGNER",
"readStatus": "OPENED",
"signingStatus": "NOT_SIGNED",
"sendStatus": "SENT"
}
]
},
"createdAt": "2024-04-22T12:00:00.000Z",
"webhookEndpoint": "https://mywebhooksite.com/mywebhook"
}
```
Example payload for the `template.created` event:
```json
{
"event": "TEMPLATE_CREATED",
"payload": {
"id": 5,
"externalId": null,
"userId": 1,
"authOptions": null,
"formValues": null,
"visibility": "EVERYONE",
"title": "employment_contract.pdf",
"status": "DRAFT",
"documentDataId": "hs8qz1ktr9204jn7mg6c5dxy0",
"createdAt": "2024-04-22T11:44:43.341Z",
"updatedAt": "2024-04-22T11:44:43.341Z",
"completedAt": null,
"deletedAt": null,
"teamId": 2,
"templateId": 5,
"source": "TEMPLATE",
"documentMeta": {
"id": "doc_meta_456",
"subject": "Employment Contract",
"message": "Please review and sign your employment contract.",
"timezone": "UTC",
"password": null,
"dateFormat": "MM/DD/YYYY",
"redirectUrl": null,
"signingOrder": "PARALLEL",
"typedSignatureEnabled": true,
"language": "en",
"distributionMethod": "EMAIL",
"emailSettings": null
},
"Recipient": [
{
"id": 25,
"documentId": null,
"templateId": 5,
"email": "employee@company.com",
"name": "Employee",
"token": "TemplateToken123",
"documentDeletedAt": null,
"expired": null,
"signedAt": null,
"authOptions": null,
"signingOrder": 1,
"rejectionReason": null,
"role": "SIGNER",
"readStatus": "NOT_OPENED",
"signingStatus": "NOT_SIGNED",
"sendStatus": "NOT_SENT"
}
]
},
"createdAt": "2024-04-22T11:44:44.779Z",
"webhookEndpoint": "https://mywebhooksite.com/mywebhook"
}
```
Example payload for the `template.updated` event:
```json
{
"event": "TEMPLATE_UPDATED",
"payload": {
"id": 5,
"externalId": null,
"userId": 1,
"authOptions": null,
"formValues": null,
"visibility": "EVERYONE",
"title": "employment_contract_v2.pdf",
"status": "DRAFT",
"documentDataId": "hs8qz1ktr9204jn7mg6c5dxy0",
"createdAt": "2024-04-22T11:44:43.341Z",
"updatedAt": "2024-04-22T12:30:00.000Z",
"completedAt": null,
"deletedAt": null,
"teamId": 2,
"templateId": 5,
"source": "TEMPLATE",
"documentMeta": {
"id": "doc_meta_456",
"subject": "Employment Contract - Updated",
"message": "Please review and sign your employment contract.",
"timezone": "UTC",
"password": null,
"dateFormat": "MM/DD/YYYY",
"redirectUrl": null,
"signingOrder": "PARALLEL",
"typedSignatureEnabled": true,
"language": "en",
"distributionMethod": "EMAIL",
"emailSettings": null
},
"Recipient": [
{
"id": 25,
"documentId": null,
"templateId": 5,
"email": "employee@company.com",
"name": "Employee",
"token": "TemplateToken123",
"documentDeletedAt": null,
"expired": null,
"signedAt": null,
"authOptions": null,
"signingOrder": 1,
"rejectionReason": null,
"role": "SIGNER",
"readStatus": "NOT_OPENED",
"signingStatus": "NOT_SIGNED",
"sendStatus": "NOT_SENT"
}
]
},
"createdAt": "2024-04-22T12:30:01.000Z",
"webhookEndpoint": "https://mywebhooksite.com/mywebhook"
}
```
Example payload for the `template.deleted` event:
```json
{
"event": "TEMPLATE_DELETED",
"payload": {
"id": 5,
"externalId": null,
"userId": 1,
"authOptions": null,
"formValues": null,
"visibility": "EVERYONE",
"title": "employment_contract.pdf",
"status": "DRAFT",
"documentDataId": "hs8qz1ktr9204jn7mg6c5dxy0",
"createdAt": "2024-04-22T11:44:43.341Z",
"updatedAt": "2024-04-22T11:44:43.341Z",
"completedAt": null,
"deletedAt": null,
"teamId": 2,
"templateId": 5,
"source": "TEMPLATE",
"documentMeta": {
"id": "doc_meta_456",
"subject": "Employment Contract",
"message": "Please review and sign your employment contract.",
"timezone": "UTC",
"password": null,
"dateFormat": "MM/DD/YYYY",
"redirectUrl": null,
"signingOrder": "PARALLEL",
"typedSignatureEnabled": true,
"language": "en",
"distributionMethod": "EMAIL",
"emailSettings": null
},
"Recipient": [
{
"id": 25,
"documentId": null,
"templateId": 5,
"email": "employee@company.com",
"name": "Employee",
"token": "TemplateToken123",
"documentDeletedAt": null,
"expired": null,
"signedAt": null,
"authOptions": null,
"signingOrder": 1,
"rejectionReason": null,
"role": "SIGNER",
"readStatus": "NOT_OPENED",
"signingStatus": "NOT_SIGNED",
"sendStatus": "NOT_SENT"
}
]
},
"createdAt": "2024-04-22T13:00:00.000Z",
"webhookEndpoint": "https://mywebhooksite.com/mywebhook"
}
```
Example payload for the `template.used` event:
```json
{
"event": "TEMPLATE_USED",
"payload": {
"id": 15,
"externalId": null,
"userId": 1,
"authOptions": null,
"formValues": null,
"visibility": "EVERYONE",
"title": "employment_contract.pdf",
"status": "DRAFT",
"documentDataId": "new_doc_data_123",
"createdAt": "2024-04-22T14:00:00.000Z",
"updatedAt": "2024-04-22T14:00:00.000Z",
"completedAt": null,
"deletedAt": null,
"teamId": 2,
"templateId": 5,
"source": "TEMPLATE",
"documentMeta": {
"id": "doc_meta_789",
"subject": "Employment Contract",
"message": "Please review and sign your employment contract.",
"timezone": "UTC",
"password": null,
"dateFormat": "MM/DD/YYYY",
"redirectUrl": null,
"signingOrder": "PARALLEL",
"typedSignatureEnabled": true,
"language": "en",
"distributionMethod": "EMAIL",
"emailSettings": null
},
"Recipient": [
{
"id": 60,
"documentId": 15,
"templateId": 5,
"email": "newemployee@company.com",
"name": "New Employee",
"token": "DocToken456",
"documentDeletedAt": null,
"expired": null,
"signedAt": null,
"authOptions": null,
"signingOrder": 1,
"rejectionReason": null,
"role": "SIGNER",
"readStatus": "NOT_OPENED",
"signingStatus": "NOT_SIGNED",
"sendStatus": "NOT_SENT"
}
]
},
"createdAt": "2024-04-22T14:00:01.000Z",
"webhookEndpoint": "https://mywebhooksite.com/mywebhook"
}
```
Example payload for the `recipient.authentication.failed` event:
```json
{
"event": "RECIPIENT_AUTHENTICATION_FAILED",
"payload": {
"id": 10,
"externalId": null,
"userId": 1,
"authOptions": null,
"formValues": null,
"visibility": "EVERYONE",
"title": "documenso.pdf",
"status": "PENDING",
"documentDataId": "hs8qz1ktr9204jn7mg6c5dxy0",
"createdAt": "2024-04-22T11:44:43.341Z",
"updatedAt": "2024-04-22T11:48:07.569Z",
"completedAt": null,
"deletedAt": null,
"teamId": null,
"templateId": null,
"source": "DOCUMENT",
"documentMeta": {
"id": "doc_meta_123",
"subject": "Please sign this document",
"message": "Hello, please review and sign this document.",
"timezone": "UTC",
"password": null,
"dateFormat": "MM/DD/YYYY",
"redirectUrl": null,
"signingOrder": "PARALLEL",
"typedSignatureEnabled": true,
"language": "en",
"distributionMethod": "EMAIL",
"emailSettings": null
},
"Recipient": [
{
"id": 52,
"documentId": 10,
"templateId": null,
"email": "signer@documenso.com",
"name": "Signer",
"token": "vbT8hi3jKQmrFP_LN1WcS",
"documentDeletedAt": null,
"expired": null,
"signedAt": null,
"authOptions": {
"accessAuth": "TWO_FACTOR_AUTH",
"actionAuth": null
},
"signingOrder": 1,
"rejectionReason": null,
"role": "SIGNER",
"readStatus": "NOT_OPENED",
"signingStatus": "NOT_SIGNED",
"sendStatus": "SENT"
}
]
},
"createdAt": "2024-04-22T11:49:00.000Z",
"webhookEndpoint": "https://mywebhooksite.com/mywebhook"
}
```
## Webhook events testing
You can trigger test webhook events to test the webhook functionality. To do so, navigate to the webhook subscription details page and click the "Test" button.
@@ -149,7 +149,12 @@ export const EnvelopesBulkDeleteDialog = ({
</Alert>
<DialogFooter>
<Button type="button" variant="secondary" disabled={isPending}>
<Button
type="button"
variant="secondary"
onClick={() => onOpenChange(false)}
disabled={isPending}
>
<Trans>Cancel</Trans>
</Button>
@@ -40,21 +40,13 @@ type TCreateFolderFormSchema = z.infer<typeof ZCreateFolderFormSchema>;
export type FolderCreateDialogProps = {
type: FolderType;
trigger?: React.ReactNode;
parentFolderId?: string | null;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
export const FolderCreateDialog = ({
type,
trigger,
parentFolderId,
...props
}: FolderCreateDialogProps) => {
export const FolderCreateDialog = ({ type, trigger, ...props }: FolderCreateDialogProps) => {
const { t } = useLingui();
const { toast } = useToast();
const { folderId } = useParams();
const parentId = parentFolderId ?? folderId;
const [isCreateFolderOpen, setIsCreateFolderOpen] = useState(false);
const { mutateAsync: createFolder } = trpc.folder.createFolder.useMutation();
@@ -70,7 +62,7 @@ export const FolderCreateDialog = ({
try {
await createFolder({
name: data.name,
parentId,
parentId: folderId,
type,
});
@@ -77,7 +77,7 @@ export const OrganisationGroupDeleteDialog = ({
</DialogTitle>
<DialogDescription className="mt-4">
<Trans>
<Trans context="Removing group from organisation">
You are about to remove the following group from{' '}
<span className="font-semibold">{organisation.name}</span>.
</Trans>
@@ -81,7 +81,7 @@ export const TeamGroupDeleteDialog = ({
</DialogTitle>
<DialogDescription className="mt-4">
<Trans>
<Trans context="Removing group from team">
You are about to remove the following group from{' '}
<span className="font-semibold">{team.name}</span>.
</Trans>
@@ -53,10 +53,6 @@ export const FolderGrid = ({ type, parentId }: FolderGridProps) => {
const rootPath =
type === FolderType.DOCUMENT ? formatDocumentsPath(team.url) : formatTemplatesPath(team.url);
if (parentId) {
return `${rootPath}/folders?parentId=${parentId}`;
}
return `${rootPath}/folders`;
};
@@ -0,0 +1,146 @@
import { useMemo } from 'react';
import { useLingui } from '@lingui/react';
import { useLingui as useLinguiMacro } from '@lingui/react/macro';
import { Link, useSearchParams } from 'react-router';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import { TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams-translations';
import { ZUrlSearchParamsSchema } from '@documenso/lib/types/search-params';
import type { TeamMemberRole } from '@documenso/prisma/generated/types';
import { trpc } from '@documenso/trpc/react';
import { Badge } from '@documenso/ui/primitives/badge';
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
import { DataTable } from '@documenso/ui/primitives/data-table';
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@documenso/ui/primitives/hover-card';
import { Skeleton } from '@documenso/ui/primitives/skeleton';
import { TableCell } from '@documenso/ui/primitives/table';
type AdminUserTeamsTableProps = {
userId: number;
};
export const AdminUserTeamsTable = ({ userId }: AdminUserTeamsTableProps) => {
const { i18n } = useLingui();
const { t } = useLinguiMacro();
const [searchParams] = useSearchParams();
const updateSearchParams = useUpdateSearchParams();
const parsedSearchParams = ZUrlSearchParamsSchema.parse(Object.fromEntries(searchParams ?? []));
const { data, isLoading, isLoadingError } = trpc.admin.user.findTeams.useQuery({
userId,
query: parsedSearchParams.query,
page: parsedSearchParams.page,
perPage: parsedSearchParams.perPage,
});
const onPaginationChange = (page: number, perPage: number) => {
updateSearchParams({
page,
perPage,
});
};
const results = data ?? {
data: [],
perPage: 10,
currentPage: 1,
totalPages: 1,
};
const columns = useMemo(() => {
return [
{
header: t`Team`,
accessorKey: 'name',
cell: ({ row }) => (
<HoverCard>
<HoverCardTrigger className="cursor-default underline decoration-dotted underline-offset-4">
{row.original.name}
</HoverCardTrigger>
<HoverCardContent
className="w-auto font-mono text-xs text-muted-foreground"
align="start"
>
<dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1">
<dt>id</dt>
<dd>{row.original.id}</dd>
<dt>url</dt>
<dd>{row.original.url}</dd>
</dl>
</HoverCardContent>
</HoverCard>
),
},
{
header: t`Organisation`,
accessorKey: 'organisation',
cell: ({ row }) => (
<Link
to={`/admin/organisations/${row.original.organisation.id}`}
className="hover:underline"
>
{row.original.organisation.name}
</Link>
),
},
{
header: t`Role`,
accessorKey: 'teamRole',
cell: ({ row }) => (
<Badge variant="neutral">
{i18n._(TEAM_MEMBER_ROLE_MAP[row.original.teamRole as TeamMemberRole])}
</Badge>
),
},
{
header: t`Created At`,
accessorKey: 'createdAt',
cell: ({ row }) => i18n.date(row.original.createdAt),
},
] satisfies DataTableColumnDef<(typeof results)['data'][number]>[];
}, []);
return (
<DataTable
columns={columns}
data={results.data}
perPage={results.perPage}
currentPage={results.currentPage}
totalPages={results.totalPages}
onPaginationChange={onPaginationChange}
error={{
enable: isLoadingError,
}}
skeleton={{
enable: isLoading,
rows: 3,
component: (
<>
<TableCell className="py-4 pr-4">
<Skeleton className="h-4 w-20 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-20 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-12 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-16 rounded-full" />
</TableCell>
</>
),
}}
>
{(table) =>
table.getPageCount() > 1 ? (
<DataTablePagination additionalInformation="VisibleCount" table={table} />
) : null
}
</DataTable>
);
};
@@ -24,7 +24,7 @@ export const EnvelopesTableBulkActionBar = ({
}
return (
<div className="fixed bottom-4 left-1/2 z-50 flex -translate-x-1/2 items-center gap-x-4 rounded-lg border border-border bg-widget px-4 py-3 shadow-lg">
<div className="fixed bottom-4 left-1/2 z-50 flex -translate-x-1/2 items-center gap-x-4 rounded-lg border border-border bg-background px-4 py-3 shadow-lg">
<span className="text-sm font-medium">
<Trans>{selectedCount} selected</Trans>
</span>
@@ -36,13 +36,7 @@ export const EnvelopesTableBulkActionBar = ({
<Trans>Move to Folder</Trans>
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={onDeleteClick}
className="text-destructive hover:text-destructive"
>
<Button type="button" variant="destructive" size="sm" onClick={onDeleteClick}>
<Trash2Icon className="mr-2 h-4 w-4" />
<Trans>Delete</Trans>
</Button>
@@ -10,6 +10,12 @@ import type { z } from 'zod';
import { trpc } from '@documenso/trpc/react';
import type { TGetUserResponse } from '@documenso/trpc/server/admin-router/get-user.types';
import { ZUpdateUserRequestSchema } from '@documenso/trpc/server/admin-router/update-user.types';
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@documenso/ui/primitives/accordion';
import { Button } from '@documenso/ui/primitives/button';
import {
Form,
@@ -30,6 +36,7 @@ import { AdminUserEnableDialog } from '~/components/dialogs/admin-user-enable-di
import { AdminUserResetTwoFactorDialog } from '~/components/dialogs/admin-user-reset-two-factor-dialog';
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
import { AdminOrganisationsTable } from '~/components/tables/admin-organisations-table';
import { AdminUserTeamsTable } from '~/components/tables/admin-user-teams-table';
import { MultiSelectRoleCombobox } from '../../../components/general/multiselect-role-combobox';
@@ -197,7 +204,7 @@ const AdminUserPage = ({ user }: { user: TGetUserResponse }) => {
<h3 className="text-lg font-semibold leading-none tracking-tight">
<Trans>User Organisations</Trans>
</h3>
<p className="text-muted-foreground mt-1.5 text-sm">
<p className="mt-1.5 text-sm text-muted-foreground">
<Trans>Organisations that the user is a member of.</Trans>
</p>
</div>
@@ -219,6 +226,28 @@ const AdminUserPage = ({ user }: { user: TGetUserResponse }) => {
/>
</div>
<hr className="my-8" />
<Accordion type="single" collapsible>
<AccordionItem value="team-memberships" className="border-b-0">
<AccordionTrigger className="py-0">
<div className="text-left">
<h3 className="text-lg font-semibold leading-none tracking-tight">
<Trans>Team Memberships</Trans>
</h3>
<p className="mt-1.5 text-sm font-normal text-muted-foreground">
<Trans>Teams that this user is a member of and their roles.</Trans>
</p>
</div>
</AccordionTrigger>
<AccordionContent>
<div className="mt-4">
<AdminUserTeamsTable userId={user.id} />
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
<div className="mt-16 flex flex-col gap-4">
{user && user.twoFactorEnabled && <AdminUserResetTwoFactorDialog user={user} />}
{user && user.disabled && <AdminUserEnableDialog userToEnable={user} />}
@@ -7,6 +7,7 @@ import { useParams, useSearchParams } from 'react-router';
import { Link } from 'react-router';
import { z } from 'zod';
import { useSessionStorage } from '@documenso/lib/client-only/hooks/use-session-storage';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
import { parseToIntegerArray } from '@documenso/lib/utils/params';
@@ -58,7 +59,10 @@ export default function DocumentsPage() {
const [isMovingDocument, setIsMovingDocument] = useState(false);
const [documentToMove, setDocumentToMove] = useState<number | null>(null);
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const [rowSelection, setRowSelection] = useSessionStorage<RowSelectionState>(
'documents-bulk-selection',
{},
);
const [isBulkMoveDialogOpen, setIsBulkMoveDialogOpen] = useState(false);
const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false);
@@ -121,11 +125,6 @@ export default function DocumentsPage() {
}
}, [data?.stats]);
// Clear selection when navigation or filters change
useEffect(() => {
setRowSelection({});
}, [folderId, findDocumentSearchParams]);
return (
<EnvelopeDropZoneWrapper type={EnvelopeType.DOCUMENT}>
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
@@ -1,13 +1,14 @@
import { useState } from 'react';
import { Trans, useLingui } from '@lingui/react/macro';
import { FolderIcon, HomeIcon, Loader2, SearchIcon } from 'lucide-react';
import { Link, useSearchParams } from 'react-router';
import { HomeIcon, Loader2, SearchIcon } from 'lucide-react';
import { useNavigate } from 'react-router';
import { FolderType } from '@documenso/lib/types/folder-type';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import { type TFolderWithSubfolders } from '@documenso/trpc/server/folder-router/schema';
import { Button } from '@documenso/ui/primitives/button';
import { Input } from '@documenso/ui/primitives/input';
import { FolderCreateDialog } from '~/components/dialogs/folder-create-dialog';
@@ -25,10 +26,8 @@ export function meta() {
export default function DocumentsFoldersPage() {
const { t } = useLingui();
const navigate = useNavigate();
const team = useCurrentTeam();
const [searchParams] = useSearchParams();
const parentId = searchParams.get('parentId');
const [isMovingFolder, setIsMovingFolder] = useState(false);
const [folderToMove, setFolderToMove] = useState<TFolderWithSubfolders | null>(null);
@@ -40,51 +39,44 @@ export default function DocumentsFoldersPage() {
const { data: foldersData, isLoading: isFoldersLoading } = trpc.folder.getFolders.useQuery({
type: FolderType.DOCUMENT,
parentId: parentId,
parentId: null,
});
const navigateToFolder = (folderId?: string | null) => {
const documentsPath = formatDocumentsPath(team.url);
if (folderId) {
void navigate(`${documentsPath}/f/${folderId}`);
} else {
void navigate(documentsPath);
}
};
const isFolderMatchingSearch = (folder: TFolderWithSubfolders) =>
folder.name.toLowerCase().includes(searchTerm.toLowerCase());
const formatBreadCrumbPath = (folderId: string) => {
const documentsPath = formatDocumentsPath(team.url);
return `${documentsPath}/f/${folderId}`;
};
return (
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
<div className="flex w-full items-center justify-between">
<div className="flex flex-1 items-center text-sm font-medium text-muted-foreground">
<Link
to={formatDocumentsPath(team.url)}
className="flex items-center hover:text-muted-foreground/80"
<div className="flex flex-1 items-center">
<Button
variant="ghost"
size="sm"
className="flex items-center space-x-2 pl-0 hover:bg-transparent"
onClick={() => navigateToFolder(null)}
>
<HomeIcon className="mr-2 h-4 w-4" />
<Trans>Home</Trans>
</Link>
{foldersData?.breadcrumbs.map((folder) => (
<div key={folder.id} className="flex items-center">
<span className="px-3">/</span>
<Link
to={formatBreadCrumbPath(folder.id)}
className="flex items-center hover:text-muted-foreground/80"
>
<FolderIcon className="mr-2 h-4 w-4" />
<span>{folder.name}</span>
</Link>
</div>
))}
<HomeIcon className="h-4 w-4" />
<span>Home</span>
</Button>
</div>
<div className="flex flex-col gap-y-4 sm:flex-row sm:justify-end sm:gap-x-4">
<FolderCreateDialog type={FolderType.DOCUMENT} parentFolderId={parentId} />
<FolderCreateDialog type={FolderType.DOCUMENT} />
</div>
</div>
<div className="relative w-full max-w-md py-6">
<SearchIcon className="absolute left-2 top-9 h-4 w-4 text-muted-foreground" />
<SearchIcon className="text-muted-foreground absolute left-2 top-9 h-4 w-4" />
<Input
placeholder={t`Search folders...`}
value={searchTerm}
@@ -99,7 +91,7 @@ export default function DocumentsFoldersPage() {
{isFoldersLoading ? (
<div className="mt-6 flex justify-center">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
<Loader2 className="text-muted-foreground h-8 w-8 animate-spin" />
</div>
) : (
<>
@@ -134,14 +126,14 @@ export default function DocumentsFoldersPage() {
<div>
{searchTerm && foldersData?.folders.filter(isFolderMatchingSearch).length === 0 && (
<div className="mt-6 text-center text-muted-foreground">
<div className="text-muted-foreground mt-6 text-center">
<Trans>No folders found matching "{searchTerm}"</Trans>
</div>
)}
<div className="mt-6 grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
{foldersData?.folders
.filter((folder) => !folder.pinned && isFolderMatchingSearch(folder))
.filter((folder) => !folder.pinned)
.map((folder) => (
<FolderCard
key={folder.id}
@@ -207,7 +207,9 @@ export default function PublicProfilePage({ loaderData }: Route.ComponentProps)
directTemplates={enabledPrivateDirectTemplates}
trigger={
<Button variant="outline">
<Trans>Link template</Trans>
<Trans context="Action button to link template to public profile">
Link template
</Trans>
</Button>
}
/>
@@ -1,10 +1,11 @@
import { useEffect, useMemo, useState } from 'react';
import { useMemo, useState } from 'react';
import { Trans } from '@lingui/react/macro';
import { EnvelopeType } from '@prisma/client';
import { Bird } from 'lucide-react';
import { useParams, useSearchParams } from 'react-router';
import { useSessionStorage } from '@documenso/lib/client-only/hooks/use-session-storage';
import { FolderType } from '@documenso/lib/types/folder-type';
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
@@ -34,7 +35,10 @@ export default function TemplatesPage() {
const page = Number(searchParams.get('page')) || 1;
const perPage = Number(searchParams.get('perPage')) || 10;
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const [rowSelection, setRowSelection] = useSessionStorage<RowSelectionState>(
'templates-bulk-selection',
{},
);
const [isBulkMoveDialogOpen, setIsBulkMoveDialogOpen] = useState(false);
const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false);
@@ -51,11 +55,6 @@ export default function TemplatesPage() {
folderId,
});
// Clear selection when navigation or filters change
useEffect(() => {
setRowSelection({});
}, [folderId, page, perPage]);
return (
<EnvelopeDropZoneWrapper type={EnvelopeType.TEMPLATE}>
<div className="mx-auto max-w-screen-xl px-4 md:px-8">
@@ -1,13 +1,14 @@
import { useState } from 'react';
import { Trans, useLingui } from '@lingui/react/macro';
import { FolderIcon, HomeIcon, Loader2, SearchIcon } from 'lucide-react';
import { Link, useSearchParams } from 'react-router';
import { HomeIcon, Loader2, SearchIcon } from 'lucide-react';
import { useNavigate } from 'react-router';
import { FolderType } from '@documenso/lib/types/folder-type';
import { formatTemplatesPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import { type TFolderWithSubfolders } from '@documenso/trpc/server/folder-router/schema';
import { Button } from '@documenso/ui/primitives/button';
import { Input } from '@documenso/ui/primitives/input';
import { FolderCreateDialog } from '~/components/dialogs/folder-create-dialog';
@@ -25,10 +26,8 @@ export function meta() {
export default function TemplatesFoldersPage() {
const { t } = useLingui();
const navigate = useNavigate();
const team = useCurrentTeam();
const [searchParams] = useSearchParams();
const parentId = searchParams.get('parentId');
const [isMovingFolder, setIsMovingFolder] = useState(false);
const [folderToMove, setFolderToMove] = useState<TFolderWithSubfolders | null>(null);
@@ -40,51 +39,44 @@ export default function TemplatesFoldersPage() {
const { data: foldersData, isLoading: isFoldersLoading } = trpc.folder.getFolders.useQuery({
type: FolderType.TEMPLATE,
parentId: parentId,
parentId: null,
});
const navigateToFolder = (folderId?: string | null) => {
const templatesPath = formatTemplatesPath(team.url);
if (folderId) {
void navigate(`${templatesPath}/f/${folderId}`);
} else {
void navigate(templatesPath);
}
};
const isFolderMatchingSearch = (folder: TFolderWithSubfolders) =>
folder.name.toLowerCase().includes(searchTerm.toLowerCase());
const formatBreadCrumbPath = (folderId: string) => {
const templatesPath = formatTemplatesPath(team.url);
return `${templatesPath}/f/${folderId}`;
};
return (
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
<div className="flex w-full items-center justify-between">
<div className="flex flex-1 items-center text-sm font-medium text-muted-foreground">
<Link
to={formatTemplatesPath(team.url)}
className="flex items-center hover:text-muted-foreground/80"
<div className="flex flex-1 items-center">
<Button
variant="ghost"
size="sm"
className="flex items-center space-x-2 pl-0 hover:bg-transparent"
onClick={() => navigateToFolder(null)}
>
<HomeIcon className="mr-2 h-4 w-4" />
<Trans>Home</Trans>
</Link>
{foldersData?.breadcrumbs.map((folder) => (
<div key={folder.id} className="flex items-center">
<span className="px-3">/</span>
<Link
to={formatBreadCrumbPath(folder.id)}
className="flex items-center hover:text-muted-foreground/80"
>
<FolderIcon className="mr-2 h-4 w-4" />
<span>{folder.name}</span>
</Link>
</div>
))}
<HomeIcon className="h-4 w-4" />
<span>Home</span>
</Button>
</div>
<div className="flex flex-col gap-y-4 sm:flex-row sm:justify-end sm:gap-x-4">
<FolderCreateDialog type={FolderType.TEMPLATE} parentFolderId={parentId} />
<FolderCreateDialog type={FolderType.TEMPLATE} />
</div>
</div>
<div className="relative w-full max-w-md py-6">
<SearchIcon className="absolute left-2 top-9 h-4 w-4 text-muted-foreground" />
<SearchIcon className="text-muted-foreground absolute left-2 top-9 h-4 w-4" />
<Input
placeholder={t`Search folders...`}
value={searchTerm}
@@ -98,8 +90,8 @@ export default function TemplatesFoldersPage() {
</h1>
{isFoldersLoading ? (
<div className="mt-6 flex justify-center">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
<div className="mt- flex justify-center">
<Loader2 className="text-muted-foreground h-8 w-8 animate-spin" />
</div>
) : (
<>
@@ -134,7 +126,7 @@ export default function TemplatesFoldersPage() {
<div>
{searchTerm && foldersData?.folders.filter(isFolderMatchingSearch).length === 0 && (
<div className="mt-6 text-center text-muted-foreground">
<div className="text-muted-foreground mt-6 text-center">
<Trans>No folders found matching "{searchTerm}"</Trans>
</div>
)}
@@ -0,0 +1,69 @@
import * as React from 'react';
import type { Dispatch, SetStateAction } from 'react';
function dispatchStorageEvent(key: string, newValue: string | null) {
window.dispatchEvent(new StorageEvent('storage', { key, newValue }));
}
const setSessionStorageItem = <T>(key: string, value: T) => {
const stringifiedValue = JSON.stringify(value);
window.sessionStorage.setItem(key, stringifiedValue);
dispatchStorageEvent(key, stringifiedValue);
};
const removeSessionStorageItem = (key: string) => {
window.sessionStorage.removeItem(key);
dispatchStorageEvent(key, null);
};
const getSessionStorageItem = (key: string) => {
return window.sessionStorage.getItem(key);
};
const useSessionStorageSubscribe = (callback: (event: StorageEvent) => void) => {
window.addEventListener('storage', callback);
return () => window.removeEventListener('storage', callback);
};
export function useSessionStorage<T>(
key: string,
initialValue: T,
): [T, Dispatch<SetStateAction<T>>] {
const serializedInitialValue = JSON.stringify(initialValue);
const getSnapshot = () => getSessionStorageItem(key);
const getServerSnapshot = () => serializedInitialValue;
const store = React.useSyncExternalStore(
useSessionStorageSubscribe,
getSnapshot,
getServerSnapshot,
);
const setState: Dispatch<SetStateAction<T>> = React.useCallback(
(v) => {
try {
const prevValue = store ? JSON.parse(store) : initialValue;
// @ts-expect-error - SetStateAction function check is safe at runtime
const nextState = typeof v === 'function' ? v(prevValue) : v;
if (nextState === undefined || nextState === null) {
removeSessionStorageItem(key);
} else {
setSessionStorageItem(key, nextState);
}
} catch (e) {
console.warn(e);
}
},
[key, store, initialValue],
);
React.useEffect(() => {
if (getSessionStorageItem(key) === null && typeof initialValue !== 'undefined') {
setSessionStorageItem(key, initialValue);
}
}, [key, initialValue]);
return [store ? JSON.parse(store) : initialValue, setState];
}
@@ -106,7 +106,9 @@ export const completeDocumentWithToken = async ({
}
if (envelope.documentMeta?.signingOrder === DocumentSigningOrder.SEQUENTIAL) {
const isRecipientsTurn = await getIsRecipientsTurnToSign({ token: recipient.token });
const isRecipientsTurn = await getIsRecipientsTurnToSign({
token: recipient.token,
});
if (!isRecipientsTurn) {
throw new Error(
@@ -195,6 +197,18 @@ export const completeDocumentWithToken = async ({
}),
});
const envelopeForFailure = await prisma.envelope.findUniqueOrThrow({
where: { id: envelope.id },
include: { documentMeta: true, recipients: true },
});
await triggerWebhook({
event: WebhookTriggerEvents.RECIPIENT_AUTHENTICATION_FAILED,
data: ZWebhookDocumentSchema.parse(mapEnvelopeToWebhookDocumentPayload(envelopeForFailure)),
userId: envelope.userId,
teamId: envelope.teamId,
});
throw new AppError(AppErrorCode.TWO_FACTOR_AUTH_FAILED, {
message: 'Invalid 2FA authentication',
});
@@ -283,6 +297,18 @@ export const completeDocumentWithToken = async ({
});
});
const envelopeWithRelations = await prisma.envelope.findUniqueOrThrow({
where: { id: envelope.id },
include: { documentMeta: true, recipients: true },
});
await triggerWebhook({
event: WebhookTriggerEvents.DOCUMENT_RECIPIENT_COMPLETED,
data: ZWebhookDocumentSchema.parse(mapEnvelopeToWebhookDocumentPayload(envelopeWithRelations)),
userId: envelope.userId,
teamId: envelope.teamId,
});
await jobs.triggerJob({
name: 'send.recipient.signed.email',
payload: {
@@ -7,6 +7,7 @@ import {
OrganisationType,
RecipientRole,
SigningStatus,
WebhookTriggerEvents,
} from '@prisma/client';
import { mailer } from '@documenso/email/mailer';
@@ -24,12 +25,17 @@ import { prisma } from '@documenso/prisma';
import { getI18nInstance } from '../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
import {
ZWebhookDocumentSchema,
mapEnvelopeToWebhookDocumentPayload,
} from '../../types/webhook-payload';
import { isDocumentCompleted } from '../../utils/document';
import type { EnvelopeIdOptions } from '../../utils/envelope';
import { isRecipientEmailValidForSending } from '../../utils/recipients';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { getEmailContext } from '../email/get-email-context';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
export type ResendDocumentOptions = {
id: EnvelopeIdOptions;
@@ -232,5 +238,12 @@ export const resendDocument = async ({
}),
);
await triggerWebhook({
event: WebhookTriggerEvents.DOCUMENT_REMINDER_SENT,
data: ZWebhookDocumentSchema.parse(mapEnvelopeToWebhookDocumentPayload(envelope)),
userId: envelope.userId,
teamId: envelope.teamId,
});
return envelope;
};
@@ -1,5 +1,4 @@
import { EnvelopeType, ReadStatus, SendStatus } from '@prisma/client';
import { WebhookTriggerEvents } from '@prisma/client';
import { EnvelopeType, ReadStatus, SendStatus, WebhookTriggerEvents } from '@prisma/client';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
@@ -56,6 +55,13 @@ export const viewedDocument = async ({
}),
});
await triggerWebhook({
event: WebhookTriggerEvents.DOCUMENT_VIEWED,
data: ZWebhookDocumentSchema.parse(mapEnvelopeToWebhookDocumentPayload(envelope)),
userId: envelope.userId,
teamId: envelope.teamId,
});
// Early return if already opened.
if (recipient.readStatus === ReadStatus.OPENED) {
return;
@@ -616,6 +616,13 @@ export const createEnvelope = async ({
userId,
teamId,
});
} else if (type === EnvelopeType.TEMPLATE) {
await triggerWebhook({
event: WebhookTriggerEvents.TEMPLATE_CREATED,
data: ZWebhookDocumentSchema.parse(mapEnvelopeToWebhookDocumentPayload(createdEnvelope)),
userId,
teamId,
});
}
return createdEnvelope;
@@ -1,6 +1,5 @@
import type { DocumentMeta, DocumentVisibility, Prisma, TemplateType } from '@prisma/client';
import { EnvelopeType, FolderType } from '@prisma/client';
import { DocumentStatus } from '@prisma/client';
import { DocumentStatus, EnvelopeType, FolderType, WebhookTriggerEvents } from '@prisma/client';
import { isDeepEqual } from 'remeda';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
@@ -12,9 +11,14 @@ import { prisma } from '@documenso/prisma';
import { TEAM_DOCUMENT_VISIBILITY_MAP } from '../../constants/teams';
import { AppError, AppErrorCode } from '../../errors/app-error';
import type { TDocumentAccessAuthTypes, TDocumentActionAuthTypes } from '../../types/document-auth';
import {
ZWebhookDocumentSchema,
mapEnvelopeToWebhookDocumentPayload,
} from '../../types/webhook-payload';
import { createDocumentAuthOptions, extractDocumentAuthMethods } from '../../utils/document-auth';
import type { EnvelopeIdOptions } from '../../utils/envelope';
import { buildTeamWhereQuery, canAccessTeamDocument } from '../../utils/teams';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
import { getEnvelopeWhereInput } from './get-envelope-by-id';
export type UpdateEnvelopeOptions = {
@@ -339,6 +343,22 @@ export const updateEnvelope = async ({
});
}
if (envelope.type === EnvelopeType.TEMPLATE) {
const envelopeWithRelations = await tx.envelope.findUniqueOrThrow({
where: { id: updatedEnvelope.id },
include: { documentMeta: true, recipients: true },
});
void triggerWebhook({
event: WebhookTriggerEvents.TEMPLATE_UPDATED,
data: ZWebhookDocumentSchema.parse(
mapEnvelopeToWebhookDocumentPayload(envelopeWithRelations),
),
userId,
teamId,
});
}
return updatedEnvelope;
});
};
@@ -764,6 +764,13 @@ export const createDocumentFromTemplate = async ({
teamId,
});
await triggerWebhook({
event: WebhookTriggerEvents.TEMPLATE_USED,
data: ZWebhookDocumentSchema.parse(mapEnvelopeToWebhookDocumentPayload(createdEnvelope)),
userId,
teamId,
});
return envelope;
});
};
@@ -1,9 +1,14 @@
import { EnvelopeType } from '@prisma/client';
import { EnvelopeType, WebhookTriggerEvents } from '@prisma/client';
import { prisma } from '@documenso/prisma';
import {
ZWebhookDocumentSchema,
mapEnvelopeToWebhookDocumentPayload,
} from '../../types/webhook-payload';
import { type EnvelopeIdOptions } from '../../utils/envelope';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
export type DeleteTemplateOptions = {
id: EnvelopeIdOptions;
@@ -19,6 +24,18 @@ export const deleteTemplate = async ({ id, userId, teamId }: DeleteTemplateOptio
teamId,
});
const templateToDelete = await prisma.envelope.findUniqueOrThrow({
where: envelopeWhereInput,
include: { documentMeta: true, recipients: true },
});
await triggerWebhook({
event: WebhookTriggerEvents.TEMPLATE_DELETED,
data: ZWebhookDocumentSchema.parse(mapEnvelopeToWebhookDocumentPayload(templateToDelete)),
userId,
teamId,
});
return await prisma.envelope.delete({
where: envelopeWhereInput,
});
@@ -20,6 +20,12 @@ export const submitSupportTicket = async ({
organisationId,
teamId,
}: SubmitSupportTicketOptions) => {
if (!plainClient) {
throw new AppError(AppErrorCode.NOT_SETUP, {
message: 'Support ticket system is not configured',
});
}
const user = await prisma.user.findFirst({
where: {
id: userId,
@@ -52,6 +58,29 @@ export const submitSupportTicket = async ({
})
: null;
// Ensure the customer exists in Plain before creating a thread
const plainCustomer = await plainClient.upsertCustomer({
identifier: {
emailAddress: user.email,
},
onCreate: {
// If the user doesn't have a name, default to their email
fullName: user.name || user.email,
email: {
email: user.email,
isVerified: !!user.emailVerified,
},
},
// No need to update the customer if it already exists
onUpdate: {},
});
if (plainCustomer.error) {
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
message: `Failed to create customer in support system: ${plainCustomer.error.message}`,
});
}
const customMessage = `
Organisation: ${organisation.name} (${organisation.id})
Team: ${team ? `${team.name} (${team.id})` : 'No team provided'}
@@ -60,12 +89,14 @@ ${message}`;
const res = await plainClient.createThread({
title: subject,
customerIdentifier: { emailAddress: user.email },
customerIdentifier: { customerId: plainCustomer.data.customer.id },
components: [{ componentText: { text: customMessage } }],
});
if (res.error) {
throw new Error(res.error.message);
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
message: `Failed to create support ticket: ${res.error.message}`,
});
}
return res;
@@ -480,5 +480,198 @@ export const generateSampleWebhookPayload = (
};
}
if (event === WebhookTriggerEvents.DOCUMENT_VIEWED) {
return {
event,
payload: {
...basePayload,
status: DocumentStatus.PENDING,
recipients: [
{
...basePayload.recipients[0],
readStatus: ReadStatus.OPENED,
},
],
Recipient: [
{
...basePayload.recipients[0],
readStatus: ReadStatus.OPENED,
},
],
},
createdAt: now.toISOString(),
webhookEndpoint: webhookUrl,
};
}
if (event === WebhookTriggerEvents.DOCUMENT_RECIPIENT_COMPLETED) {
return {
event,
payload: {
...basePayload,
status: DocumentStatus.PENDING,
recipients: [
{
...basePayload.recipients[0],
readStatus: ReadStatus.OPENED,
signingStatus: SigningStatus.SIGNED,
signedAt: now,
},
],
Recipient: [
{
...basePayload.recipients[0],
readStatus: ReadStatus.OPENED,
signingStatus: SigningStatus.SIGNED,
signedAt: now,
},
],
},
createdAt: now.toISOString(),
webhookEndpoint: webhookUrl,
};
}
if (event === WebhookTriggerEvents.DOCUMENT_DOWNLOADED) {
return {
event,
payload: {
...basePayload,
status: DocumentStatus.COMPLETED,
completedAt: now,
recipients: [
{
...basePayload.recipients[0],
readStatus: ReadStatus.OPENED,
signingStatus: SigningStatus.SIGNED,
signedAt: now,
},
],
Recipient: [
{
...basePayload.recipients[0],
readStatus: ReadStatus.OPENED,
signingStatus: SigningStatus.SIGNED,
signedAt: now,
},
],
},
createdAt: now.toISOString(),
webhookEndpoint: webhookUrl,
};
}
if (event === WebhookTriggerEvents.DOCUMENT_REMINDER_SENT) {
return {
event,
payload: {
...basePayload,
status: DocumentStatus.PENDING,
recipients: [
{
...basePayload.recipients[0],
sendStatus: SendStatus.SENT,
signingStatus: SigningStatus.NOT_SIGNED,
},
],
Recipient: [
{
...basePayload.recipients[0],
sendStatus: SendStatus.SENT,
signingStatus: SigningStatus.NOT_SIGNED,
},
],
},
createdAt: now.toISOString(),
webhookEndpoint: webhookUrl,
};
}
if (event === WebhookTriggerEvents.RECIPIENT_AUTHENTICATION_FAILED) {
return {
event,
payload: {
...basePayload,
status: DocumentStatus.PENDING,
recipients: [
{
...basePayload.recipients[0],
readStatus: ReadStatus.NOT_OPENED,
signingStatus: SigningStatus.NOT_SIGNED,
},
],
Recipient: [
{
...basePayload.recipients[0],
readStatus: ReadStatus.NOT_OPENED,
signingStatus: SigningStatus.NOT_SIGNED,
},
],
},
createdAt: now.toISOString(),
webhookEndpoint: webhookUrl,
};
}
if (event === WebhookTriggerEvents.TEMPLATE_CREATED) {
return {
event,
payload: {
...basePayload,
title: 'My Template',
status: DocumentStatus.DRAFT,
templateId: 10,
source: DocumentSource.TEMPLATE,
},
createdAt: now.toISOString(),
webhookEndpoint: webhookUrl,
};
}
if (event === WebhookTriggerEvents.TEMPLATE_UPDATED) {
return {
event,
payload: {
...basePayload,
title: 'My Updated Template',
status: DocumentStatus.DRAFT,
templateId: 10,
source: DocumentSource.TEMPLATE,
},
createdAt: now.toISOString(),
webhookEndpoint: webhookUrl,
};
}
if (event === WebhookTriggerEvents.TEMPLATE_DELETED) {
return {
event,
payload: {
...basePayload,
title: 'Deleted Template',
status: DocumentStatus.DRAFT,
templateId: 10,
source: DocumentSource.TEMPLATE,
},
createdAt: now.toISOString(),
webhookEndpoint: webhookUrl,
};
}
if (event === WebhookTriggerEvents.TEMPLATE_USED) {
return {
event,
payload: {
...basePayload,
title: 'Document from Template',
status: DocumentStatus.DRAFT,
templateId: 10,
source: DocumentSource.TEMPLATE,
},
createdAt: now.toISOString(),
webhookEndpoint: webhookUrl,
};
}
throw new Error(`Unsupported event type: ${event}`);
};
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+153 -101
View File
@@ -294,8 +294,10 @@ export const formatDocumentAuditLogAction = (
auditLog: TDocumentAuditLog,
userId?: number,
) => {
const prefix =
userId === auditLog.userId ? i18n._(msg`You`) : auditLog.name || auditLog.email || '';
const isCurrentUser = userId === auditLog.userId;
const user = auditLog.name || auditLog.email || '';
const prefix = isCurrentUser ? i18n._(msg`You`) : user || '';
const description = match(auditLog)
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_CREATED }, () => ({
@@ -303,245 +305,295 @@ export const formatDocumentAuditLogAction = (
message: `A field was added`,
context: `Audit log format`,
}),
identified: msg`${prefix} added a field`,
you: msg`You added a field`,
user: msg`${user} added a field`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_DELETED }, () => ({
anonymous: msg({
message: `A field was removed`,
context: `Audit log format`,
}),
identified: msg`${prefix} removed a field`,
you: msg`You removed a field`,
user: msg`${user} removed a field`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_UPDATED }, () => ({
anonymous: msg({
message: `A field was updated`,
context: `Audit log format`,
}),
identified: msg`${prefix} updated a field`,
you: msg`You updated a field`,
user: msg`${user} updated a field`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_CREATED }, () => ({
anonymous: msg({
message: `A recipient was added`,
context: `Audit log format`,
}),
identified: msg`${prefix} added a recipient`,
you: msg`You added a recipient`,
user: msg`${user} added a recipient`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_DELETED }, () => ({
anonymous: msg({
message: `A recipient was removed`,
context: `Audit log format`,
}),
identified: msg`${prefix} removed a recipient`,
you: msg`You removed a recipient`,
user: msg`${user} removed a recipient`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_UPDATED }, () => ({
anonymous: msg({
message: `A recipient was updated`,
context: `Audit log format`,
}),
identified: msg`${prefix} updated a recipient`,
you: msg`You updated a recipient`,
user: msg`${user} updated a recipient`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_CREATED }, () => ({
anonymous: msg({
message: `Document created`,
context: `Audit log format`,
}),
identified: msg`${prefix} created the document`,
you: msg`You created the document`,
user: msg`${user} created the document`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELETED }, () => ({
anonymous: msg({
message: `Document deleted`,
context: `Audit log format`,
}),
identified: msg`${prefix} deleted the document`,
you: msg`You deleted the document`,
user: msg`${user} deleted the document`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELDS_AUTO_INSERTED }, () => ({
anonymous: msg({
message: `System auto inserted fields`,
context: `Audit log format`,
}),
identified: msg`System auto inserted fields`,
you: msg({
message: `System auto inserted fields`,
context: `Audit log format`,
}),
user: msg({
message: `System auto inserted fields`,
context: `Audit log format`,
}),
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_INSERTED }, () => ({
anonymous: msg({
message: `Field signed`,
context: `Audit log format`,
}),
identified: msg`${prefix} signed a field`,
you: msg`You signed a field`,
user: msg`${user} signed a field`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_UNINSERTED }, () => ({
anonymous: msg({
message: `Field unsigned`,
context: `Audit log format`,
}),
identified: msg`${prefix} unsigned a field`,
you: msg`You unsigned a field`,
user: msg`${user} unsigned a field`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_PREFILLED }, () => ({
anonymous: msg({
message: `Field prefilled by assistant`,
context: `Audit log format`,
}),
identified: msg`${prefix} prefilled a field`,
you: msg`You prefilled a field`,
user: msg`${user} prefilled a field`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_VISIBILITY_UPDATED }, () => ({
anonymous: msg({
message: `Document visibility updated`,
context: `Audit log format`,
}),
identified: msg`${prefix} updated the document visibility`,
you: msg`You updated the document visibility`,
user: msg`${user} updated the document visibility`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_GLOBAL_AUTH_ACCESS_UPDATED }, () => ({
anonymous: msg({
message: `Document access auth updated`,
context: `Audit log format`,
}),
identified: msg`${prefix} updated the document access auth requirements`,
you: msg`You updated the document access auth requirements`,
user: msg`${user} updated the document access auth requirements`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_GLOBAL_AUTH_ACTION_UPDATED }, () => ({
anonymous: msg({
message: `Document signing auth updated`,
context: `Audit log format`,
}),
identified: msg`${prefix} updated the document signing auth requirements`,
you: msg`You updated the document signing auth requirements`,
user: msg`${user} updated the document signing auth requirements`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_META_UPDATED }, () => ({
anonymous: msg({
message: `Document updated`,
context: `Audit log format`,
}),
identified: msg`${prefix} updated the document`,
you: msg`You updated the document`,
user: msg`${user} updated the document`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED }, () => ({
anonymous: msg({
message: `Document opened`,
context: `Audit log format`,
}),
identified: msg`${prefix} opened the document`,
you: msg`You opened the document`,
user: msg`${user} opened the document`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_VIEWED }, () => ({
anonymous: msg({
message: `Document viewed`,
context: `Audit log format`,
}),
identified: msg`${prefix} viewed the document`,
you: msg`You viewed the document`,
user: msg`${user} viewed the document`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_TITLE_UPDATED }, () => ({
anonymous: msg({
message: `Document title updated`,
context: `Audit log format`,
}),
identified: msg`${prefix} updated the document title`,
you: msg`You updated the document title`,
user: msg`${user} updated the document title`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_EXTERNAL_ID_UPDATED }, () => ({
anonymous: msg({
message: `Document external ID updated`,
context: `Audit log format`,
}),
identified: msg`${prefix} updated the document external ID`,
you: msg`You updated the document external ID`,
user: msg`${user} updated the document external ID`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_SENT }, () => ({
anonymous: msg({
message: `Document sent`,
context: `Audit log format`,
}),
identified: msg`${prefix} sent the document`,
you: msg`You sent the document`,
user: msg`${user} sent the document`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_MOVED_TO_TEAM }, () => ({
anonymous: msg({
message: `Document moved to team`,
context: `Audit log format`,
}),
identified: msg`${prefix} moved the document to team`,
you: msg`You moved the document to team`,
user: msg`${user} moved the document to team`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED }, ({ data }) => {
const userName = prefix || i18n._(msg`Recipient`);
const result = match(data.recipientRole)
.with(RecipientRole.SIGNER, () => msg`${userName} signed the document`)
.with(RecipientRole.VIEWER, () => msg`${userName} viewed the document`)
.with(RecipientRole.APPROVER, () => msg`${userName} approved the document`)
.with(RecipientRole.CC, () => msg`${userName} CC'd the document`)
.otherwise(() => msg`${userName} completed their task`);
return {
anonymous: result,
identified: result,
};
return match(data.recipientRole)
.with(RecipientRole.SIGNER, () => ({
anonymous: msg`Recipient signed the document`,
you: msg`You signed the document`,
user: msg`${user} signed the document`,
}))
.with(RecipientRole.VIEWER, () => ({
anonymous: msg`Recipient viewed the document`,
you: msg`You viewed the document`,
user: msg`${user} viewed the document`,
}))
.with(RecipientRole.APPROVER, () => ({
anonymous: msg`Recipient approved the document`,
you: msg`You approved the document`,
user: msg`${user} approved the document`,
}))
.with(RecipientRole.CC, () => ({
anonymous: msg`Recipient CC'd the document`,
you: msg`You CC'd the document`,
user: msg`${user} CC'd the document`,
}))
.otherwise(() => ({
anonymous: msg`Recipient completed their task`,
you: msg`You completed your task`,
user: msg`${user} completed their task`,
}));
})
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_REJECTED }, ({ data }) => {
const userName = prefix || i18n._(msg`Recipient`);
const result = msg`${userName} rejected the document`;
return {
anonymous: result,
identified: result,
};
})
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_ACCESS_AUTH_2FA_REQUESTED }, ({ data }) => {
const userName = prefix || i18n._(msg`Recipient`);
const result = msg`${userName} requested a 2FA token for the document`;
return {
anonymous: result,
identified: result,
};
})
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_ACCESS_AUTH_2FA_VALIDATED }, ({ data }) => {
const userName = prefix || i18n._(msg`Recipient`);
const result = msg`${userName} validated a 2FA token for the document`;
return {
anonymous: result,
identified: result,
};
})
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_ACCESS_AUTH_2FA_FAILED }, ({ data }) => {
const userName = prefix || i18n._(msg`Recipient`);
const result = msg`${userName} failed to validate a 2FA token for the document`;
return {
anonymous: result,
identified: result,
};
})
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT }, ({ data }) => ({
anonymous: data.isResending ? msg`Email resent` : msg`Email sent`,
identified: data.isResending
? msg`${prefix} resent an email to ${data.recipientEmail}`
: msg`${prefix} sent an email to ${data.recipientEmail}`,
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_REJECTED }, () => ({
anonymous: msg`Recipient rejected the document`,
you: msg`You rejected the document`,
user: msg`${user} rejected the document`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_ACCESS_AUTH_2FA_REQUESTED }, () => ({
anonymous: msg`Recipient requested a 2FA token for the document`,
you: msg`You requested a 2FA token for the document`,
user: msg`${user} requested a 2FA token for the document`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_ACCESS_AUTH_2FA_VALIDATED }, () => ({
anonymous: msg`Recipient validated a 2FA token for the document`,
you: msg`You validated a 2FA token for the document`,
user: msg`${user} validated a 2FA token for the document`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_ACCESS_AUTH_2FA_FAILED }, () => ({
anonymous: msg`Recipient failed to validate a 2FA token for the document`,
you: msg`You failed to validate a 2FA token for the document`,
user: msg`${user} failed to validate a 2FA token for the document`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT }, ({ data }) => {
if (data.isResending) {
return {
anonymous: msg({
message: `Email resent`,
context: `Audit log format`,
}),
you: msg`You resent an email to ${data.recipientEmail}`,
user: msg`${user} resent an email to ${data.recipientEmail}`,
};
}
return {
anonymous: msg({
message: `Email sent`,
context: `Audit log format`,
}),
you: msg`You sent an email to ${data.recipientEmail}`,
user: msg`${user} sent an email to ${data.recipientEmail}`,
};
})
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_COMPLETED }, () => ({
anonymous: msg({
message: `Document completed`,
context: `Audit log format`,
}),
identified: msg({
message: `Document completed`,
context: `Audit log format`,
}),
anonymous: msg({ message: `Document completed`, context: `Audit log format` }),
you: msg({ message: `Document completed`, context: `Audit log format` }),
user: msg({ message: `Document completed`, context: `Audit log format` }),
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.ENVELOPE_ITEM_CREATED }, ({ data }) => ({
anonymous: msg`Envelope item created`,
identified: msg`${prefix} created an envelope item with title ${data.envelopeItemTitle}`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.ENVELOPE_ITEM_DELETED }, ({ data }) => ({
anonymous: msg`Envelope item deleted`,
identified: msg`${prefix} deleted an envelope item with title ${data.envelopeItemTitle}`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELEGATED_OWNER_CREATED }, ({ data }) => ({
anonymous: msg({
message: `Document ownership delegated`,
message: `Envelope item created`,
context: `Audit log format`,
}),
identified: msg`The document ownership was delegated to ${data.delegatedOwnerName || data.delegatedOwnerEmail} on behalf of ${data.teamName}`,
you: msg`You created an envelope item with title ${data.envelopeItemTitle}`,
user: msg`${user} created an envelope item with title ${data.envelopeItemTitle}`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.ENVELOPE_ITEM_DELETED }, ({ data }) => ({
anonymous: msg({
message: `Envelope item deleted`,
context: `Audit log format`,
}),
you: msg`You deleted an envelope item with title ${data.envelopeItemTitle}`,
user: msg`${user} deleted an envelope item with title ${data.envelopeItemTitle}`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELEGATED_OWNER_CREATED }, ({ data }) => {
const message = msg({
message: `The document ownership was delegated to ${data.delegatedOwnerName || data.delegatedOwnerEmail} on behalf of ${data.teamName}`,
context: `Audit log format`,
});
return {
anonymous: message,
you: message,
user: message,
};
})
.exhaustive();
let selectedDescription = description.anonymous;
if (isCurrentUser) {
selectedDescription = description.you;
} else if (user) {
selectedDescription = description.user;
}
return {
prefix,
description: i18n._(prefix ? description.identified : description.anonymous),
description: i18n._(selectedDescription),
};
};
@@ -0,0 +1,17 @@
-- AlterEnum
-- This migration adds more than one value to an enum.
-- With PostgreSQL versions 11 and earlier, this is not possible
-- in a single migration. This can be worked around by creating
-- multiple migrations, each migration adding only one value to
-- the enum.
ALTER TYPE "WebhookTriggerEvents" ADD VALUE 'DOCUMENT_VIEWED';
ALTER TYPE "WebhookTriggerEvents" ADD VALUE 'DOCUMENT_RECIPIENT_COMPLETED';
ALTER TYPE "WebhookTriggerEvents" ADD VALUE 'DOCUMENT_DOWNLOADED';
ALTER TYPE "WebhookTriggerEvents" ADD VALUE 'DOCUMENT_REMINDER_SENT';
ALTER TYPE "WebhookTriggerEvents" ADD VALUE 'TEMPLATE_CREATED';
ALTER TYPE "WebhookTriggerEvents" ADD VALUE 'TEMPLATE_UPDATED';
ALTER TYPE "WebhookTriggerEvents" ADD VALUE 'TEMPLATE_DELETED';
ALTER TYPE "WebhookTriggerEvents" ADD VALUE 'TEMPLATE_USED';
ALTER TYPE "WebhookTriggerEvents" ADD VALUE 'RECIPIENT_AUTHENTICATION_FAILED';
+9
View File
@@ -172,6 +172,15 @@ enum WebhookTriggerEvents {
DOCUMENT_COMPLETED
DOCUMENT_REJECTED
DOCUMENT_CANCELLED
DOCUMENT_VIEWED
DOCUMENT_RECIPIENT_COMPLETED
DOCUMENT_DOWNLOADED
DOCUMENT_REMINDER_SENT
TEMPLATE_CREATED
TEMPLATE_UPDATED
TEMPLATE_DELETED
TEMPLATE_USED
RECIPIENT_AUTHENTICATION_FAILED
}
model Webhook {
@@ -0,0 +1,107 @@
import { Prisma } from '@prisma/client';
import type { FindResultResponse } from '@documenso/lib/types/search-params';
import { getHighestTeamRoleInGroup } from '@documenso/lib/utils/teams';
import { prisma } from '@documenso/prisma';
import { adminProcedure } from '../trpc';
import { ZFindUserTeamsRequestSchema, ZFindUserTeamsResponseSchema } from './find-user-teams.types';
export const findUserTeamsRoute = adminProcedure
.input(ZFindUserTeamsRequestSchema)
.output(ZFindUserTeamsResponseSchema)
.query(async ({ input }) => {
const { userId, query, page, perPage } = input;
return await findUserTeams({
userId,
query,
page,
perPage,
});
});
type FindUserTeamsOptions = {
userId: number;
query?: string;
page?: number;
perPage?: number;
};
const findUserTeams = async ({ userId, query, page = 1, perPage = 10 }: FindUserTeamsOptions) => {
const whereClause: Prisma.TeamWhereInput = {
teamGroups: {
some: {
organisationGroup: {
organisationGroupMembers: {
some: {
organisationMember: {
userId,
},
},
},
},
},
},
};
if (query && query.length > 0) {
whereClause.name = {
contains: query,
mode: Prisma.QueryMode.insensitive,
};
}
const [data, count] = await Promise.all([
prisma.team.findMany({
where: whereClause,
skip: Math.max(page - 1, 0) * perPage,
take: perPage,
orderBy: {
createdAt: 'desc',
},
include: {
organisation: {
select: {
id: true,
name: true,
url: true,
},
},
teamGroups: {
where: {
organisationGroup: {
organisationGroupMembers: {
some: {
organisationMember: {
userId,
},
},
},
},
},
},
},
}),
prisma.team.count({
where: whereClause,
}),
]);
const mappedData = data.map((team) => ({
id: team.id,
name: team.name,
url: team.url,
createdAt: team.createdAt,
teamRole: getHighestTeamRoleInGroup(team.teamGroups),
organisation: team.organisation,
}));
return {
data: mappedData,
count,
currentPage: Math.max(page, 1),
perPage,
totalPages: Math.ceil(count / perPage),
} satisfies FindResultResponse<typeof mappedData>;
};
@@ -0,0 +1,31 @@
import { z } from 'zod';
import { ZFindResultResponse, ZFindSearchParamsSchema } from '@documenso/lib/types/search-params';
import { TeamMemberRoleSchema } from '@documenso/prisma/generated/zod/inputTypeSchemas/TeamMemberRoleSchema';
import OrganisationSchema from '@documenso/prisma/generated/zod/modelSchema/OrganisationSchema';
import TeamSchema from '@documenso/prisma/generated/zod/modelSchema/TeamSchema';
export const ZFindUserTeamsRequestSchema = ZFindSearchParamsSchema.extend({
userId: z.number(),
});
export const ZFindUserTeamsResponseSchema = ZFindResultResponse.extend({
data: TeamSchema.pick({
id: true,
name: true,
url: true,
createdAt: true,
})
.extend({
teamRole: TeamMemberRoleSchema,
organisation: OrganisationSchema.pick({
id: true,
name: true,
url: true,
}),
})
.array(),
});
export type TFindUserTeamsRequest = z.infer<typeof ZFindUserTeamsRequestSchema>;
export type TFindUserTeamsResponse = z.infer<typeof ZFindUserTeamsResponseSchema>;
@@ -12,6 +12,7 @@ import { findDocumentAuditLogsRoute } from './find-document-audit-logs';
import { findDocumentJobsRoute } from './find-document-jobs';
import { findDocumentsRoute } from './find-documents';
import { findSubscriptionClaimsRoute } from './find-subscription-claims';
import { findUserTeamsRoute } from './find-user-teams';
import { getAdminOrganisationRoute } from './get-admin-organisation';
import { getUserRoute } from './get-user';
import { promoteMemberToOwnerRoute } from './promote-member-to-owner';
@@ -55,6 +56,7 @@ export const adminRouter = router({
enable: enableUserRoute,
disable: disableUserRoute,
resetTwoFactor: resetTwoFactorRoute,
findTeams: findUserTeamsRoute,
},
document: {
find: findDocumentsRoute,