Compare commits

..

3 Commits

Author SHA1 Message Date
4f9073a7f0 Merge branch 'main' into feat/add-webhook-logs 2025-11-25 11:39:50 +11:00
2dc88c55ab fix: build issue 2025-11-24 19:12:00 +11:00
c63c1b8963 feat: add webhook logs 2025-11-24 18:43:59 +11:00
24 changed files with 1737 additions and 508 deletions

View File

@ -38,7 +38,7 @@ import { useCurrentTeam } from '~/providers/team';
import { WebhookMultiSelectCombobox } from '../general/webhook-multiselect-combobox'; import { WebhookMultiSelectCombobox } from '../general/webhook-multiselect-combobox';
const ZCreateWebhookFormSchema = ZCreateWebhookRequestSchema.omit({ teamId: true }); const ZCreateWebhookFormSchema = ZCreateWebhookRequestSchema;
type TCreateWebhookFormSchema = z.infer<typeof ZCreateWebhookFormSchema>; type TCreateWebhookFormSchema = z.infer<typeof ZCreateWebhookFormSchema>;
@ -78,7 +78,6 @@ export const WebhookCreateDialog = ({ trigger, ...props }: WebhookCreateDialogPr
eventTriggers, eventTriggers,
secret, secret,
webhookUrl, webhookUrl,
teamId: team.id,
}); });
setOpen(false); setOpen(false);

View File

@ -67,7 +67,7 @@ export const WebhookDeleteDialog = ({ webhook, children }: WebhookDeleteDialogPr
const onSubmit = async () => { const onSubmit = async () => {
try { try {
await deleteWebhook({ id: webhook.id, teamId: team.id }); await deleteWebhook({ id: webhook.id });
toast({ toast({
title: _(msg`Webhook deleted`), title: _(msg`Webhook deleted`),
@ -146,26 +146,18 @@ export const WebhookDeleteDialog = ({ webhook, children }: WebhookDeleteDialogPr
/> />
<DialogFooter> <DialogFooter>
<div className="flex w-full flex-nowrap gap-4"> <Button type="button" variant="secondary" onClick={() => setOpen(false)}>
<Button
type="button"
variant="secondary"
className="flex-1"
onClick={() => setOpen(false)}
>
<Trans>Cancel</Trans> <Trans>Cancel</Trans>
</Button> </Button>
<Button <Button
type="submit" type="submit"
variant="destructive" variant="destructive"
className="flex-1"
disabled={!form.formState.isValid} disabled={!form.formState.isValid}
loading={form.formState.isSubmitting} loading={form.formState.isSubmitting}
> >
<Trans>I'm sure! Delete it</Trans> <Trans>Delete</Trans>
</Button> </Button>
</div>
</DialogFooter> </DialogFooter>
</fieldset> </fieldset>
</form> </form>

View File

@ -0,0 +1,225 @@
import { useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import type { Webhook } from '@prisma/client';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { useForm } from 'react-hook-form';
import type { z } from 'zod';
import { trpc } from '@documenso/trpc/react';
import { ZEditWebhookRequestSchema } from '@documenso/trpc/server/webhook-router/schema';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { PasswordInput } from '@documenso/ui/primitives/password-input';
import { Switch } from '@documenso/ui/primitives/switch';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { WebhookMultiSelectCombobox } from '../general/webhook-multiselect-combobox';
const ZEditWebhookFormSchema = ZEditWebhookRequestSchema.omit({ id: true });
type TEditWebhookFormSchema = z.infer<typeof ZEditWebhookFormSchema>;
export type WebhookEditDialogProps = {
trigger?: React.ReactNode;
webhook: Webhook;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
export const WebhookEditDialog = ({ trigger, webhook, ...props }: WebhookEditDialogProps) => {
const { t } = useLingui();
const { toast } = useToast();
const [open, setOpen] = useState(false);
const { mutateAsync: updateWebhook } = trpc.webhook.editWebhook.useMutation();
const form = useForm<TEditWebhookFormSchema>({
resolver: zodResolver(ZEditWebhookFormSchema),
values: {
webhookUrl: webhook?.webhookUrl ?? '',
eventTriggers: webhook?.eventTriggers ?? [],
secret: webhook?.secret ?? '',
enabled: webhook?.enabled ?? true,
},
});
const onSubmit = async (data: TEditWebhookFormSchema) => {
try {
await updateWebhook({
id: webhook.id,
...data,
});
toast({
title: t`Webhook updated`,
description: t`The webhook has been updated successfully.`,
duration: 5000,
});
} catch (err) {
toast({
title: t`Failed to update webhook`,
description: t`We encountered an error while updating the webhook. Please try again later.`,
variant: 'destructive',
});
}
};
return (
<Dialog
open={open}
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
{...props}
>
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
{trigger}
</DialogTrigger>
<DialogContent className="max-w-lg" position="center">
<DialogHeader>
<DialogTitle>
<Trans>Edit webhook</Trans>
</DialogTitle>
<DialogDescription>{webhook.id}</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<fieldset
className="flex h-full flex-col gap-y-6"
disabled={form.formState.isSubmitting}
>
<div className="flex flex-col-reverse gap-4 md:flex-row">
<FormField
control={form.control}
name="webhookUrl"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel required>Webhook URL</FormLabel>
<FormControl>
<Input className="bg-background" {...field} />
</FormControl>
<FormDescription>
<Trans>The URL for Documenso to send webhook events to.</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="enabled"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Enabled</Trans>
</FormLabel>
<div>
<FormControl>
<Switch
className="bg-background"
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</div>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="eventTriggers"
render={({ field: { onChange, value } }) => (
<FormItem className="flex flex-col gap-2">
<FormLabel required>
<Trans>Triggers</Trans>
</FormLabel>
<FormControl>
<WebhookMultiSelectCombobox
listValues={value}
onChange={(values: string[]) => {
onChange(values);
}}
/>
</FormControl>
<FormDescription>
<Trans>The events that will trigger a webhook to be sent to your URL.</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="secret"
render={({ field }) => (
<FormItem>
<FormLabel>Secret</FormLabel>
<FormControl>
<PasswordInput
className="bg-background"
{...field}
value={field.value ?? ''}
/>
</FormControl>
<FormDescription>
<Trans>
A secret that will be sent to your URL so you can verify that the request
has been sent by Documenso.
</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<DialogClose asChild>
<Button variant="secondary">
<Trans>Close</Trans>
</Button>
</DialogClose>
<Button type="submit" loading={form.formState.isSubmitting}>
<Trans>Update</Trans>
</Button>
</DialogFooter>
</fieldset>
</form>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@ -36,8 +36,6 @@ import {
} from '@documenso/ui/primitives/select'; } from '@documenso/ui/primitives/select';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
import { useCurrentTeam } from '~/providers/team';
export type WebhookTestDialogProps = { export type WebhookTestDialogProps = {
webhook: Pick<Webhook, 'id' | 'webhookUrl' | 'eventTriggers'>; webhook: Pick<Webhook, 'id' | 'webhookUrl' | 'eventTriggers'>;
children: React.ReactNode; children: React.ReactNode;
@ -53,8 +51,6 @@ export const WebhookTestDialog = ({ webhook, children }: WebhookTestDialogProps)
const { t } = useLingui(); const { t } = useLingui();
const { toast } = useToast(); const { toast } = useToast();
const team = useCurrentTeam();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const { mutateAsync: testWebhook } = trpc.webhook.testWebhook.useMutation(); const { mutateAsync: testWebhook } = trpc.webhook.testWebhook.useMutation();
@ -71,7 +67,6 @@ export const WebhookTestDialog = ({ webhook, children }: WebhookTestDialogProps)
await testWebhook({ await testWebhook({
id: webhook.id, id: webhook.id,
event, event,
teamId: team.id,
}); });
toast({ toast({
@ -150,11 +145,11 @@ export const WebhookTestDialog = ({ webhook, children }: WebhookTestDialogProps)
<DialogFooter> <DialogFooter>
<Button type="button" variant="secondary" onClick={() => setOpen(false)}> <Button type="button" variant="secondary" onClick={() => setOpen(false)}>
<Trans>Cancel</Trans> <Trans>Close</Trans>
</Button> </Button>
<Button type="submit" loading={form.formState.isSubmitting}> <Button type="submit" loading={form.formState.isSubmitting}>
<Trans>Send Test Webhook</Trans> <Trans>Send</Trans>
</Button> </Button>
</DialogFooter> </DialogFooter>
</fieldset> </fieldset>

View File

@ -0,0 +1,198 @@
import { useMemo, useState } from 'react';
import { Trans, useLingui } from '@lingui/react/macro';
import { WebhookCallStatus } from '@prisma/client';
import { RotateCwIcon } from 'lucide-react';
import { createCallable } from 'react-call';
import { toFriendlyWebhookEventName } from '@documenso/lib/universal/webhook/to-friendly-webhook-event-name';
import { trpc } from '@documenso/trpc/react';
import type { TFindWebhookCallsResponse } from '@documenso/trpc/server/webhook-router/find-webhook-calls.types';
import { CopyTextButton } from '@documenso/ui/components/common/copy-text-button';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { Sheet, SheetContent, SheetTitle } from '@documenso/ui/primitives/sheet';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type WebhookLogsSheetProps = {
webhookCall: TFindWebhookCallsResponse['data'][number];
};
export const WebhookLogsSheet = createCallable<WebhookLogsSheetProps, string | null>(
({ call, webhookCall: initialWebhookCall }) => {
const { t } = useLingui();
const { toast } = useToast();
const [webhookCall, setWebhookCall] = useState(initialWebhookCall);
const [activeTab, setActiveTab] = useState<'request' | 'response'>('request');
const { mutateAsync: resendWebhookCall, isPending: isResending } =
trpc.webhook.calls.resend.useMutation({
onSuccess: (result) => {
toast({ title: t`Webhook successfully sent` });
setWebhookCall(result);
},
onError: () => {
toast({ title: t`Something went wrong` });
},
});
const generalWebhookDetails = useMemo(() => {
return [
{
header: t`Status`,
value: webhookCall.status === WebhookCallStatus.SUCCESS ? t`Success` : t`Failed`,
},
{
header: t`Event`,
value: toFriendlyWebhookEventName(webhookCall.event),
},
{
header: t`Sent`,
value: new Date(webhookCall.createdAt).toLocaleString(),
},
{
header: t`Response Code`,
value: webhookCall.responseCode,
},
{
header: t`Destination`,
value: webhookCall.url,
},
];
}, [webhookCall]);
return (
<Sheet open={true} onOpenChange={(value) => (!value ? call.end(null) : null)}>
<SheetContent position="right" size="lg" className="max-w-2xl overflow-y-auto">
<SheetTitle>
<h2 className="text-lg font-semibold">
<Trans>Webhook Details</Trans>
</h2>
<p className="text-muted-foreground font-mono text-xs">{webhookCall.id}</p>
</SheetTitle>
{/* Content */}
<div className="flex-1 overflow-y-auto">
<div className="mt-6">
<div className="flex items-end justify-between">
<h4 className="text-muted-foreground mb-3 text-xs font-semibold uppercase tracking-wider">
<Trans>Details</Trans>
</h4>
<Button
onClick={() =>
resendWebhookCall({
webhookId: webhookCall.webhookId,
webhookCallId: webhookCall.id,
})
}
tabIndex={-1}
loading={isResending}
size="sm"
className="mb-2"
>
{!isResending && <RotateCwIcon className="mr-2 h-3.5 w-3.5" />}
<Trans>Resend</Trans>
</Button>
</div>
<div className="border-border overflow-hidden rounded-lg border">
<table className="w-full text-left text-sm">
<tbody className="divide-border bg-muted/30 divide-y">
{generalWebhookDetails.map(({ header, value }, index) => (
<tr key={index}>
<td className="text-muted-foreground border-border w-1/3 border-r px-4 py-2 font-mono text-xs">
{header}
</td>
<td className="text-foreground break-all px-4 py-2 font-mono text-xs">
{value}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* Payload Tabs */}
<div className="py-6">
<div className="border-border mb-4 flex items-center gap-4 border-b">
<button
onClick={() => setActiveTab('request')}
className={cn(
'relative pb-2 text-sm font-medium transition-colors',
activeTab === 'request'
? 'text-foreground after:bg-primary after:absolute after:bottom-0 after:left-0 after:right-0 after:h-0.5'
: 'text-muted-foreground hover:text-foreground',
)}
>
<Trans>Request</Trans>
</button>
<button
onClick={() => setActiveTab('response')}
className={cn(
'relative pb-2 text-sm font-medium transition-colors',
activeTab === 'response'
? 'text-foreground after:bg-primary after:absolute after:bottom-0 after:left-0 after:right-0 after:h-0.5'
: 'text-muted-foreground hover:text-foreground',
)}
>
<Trans>Response</Trans>
</button>
</div>
<div className="group relative">
<div className="absolute right-2 top-2 opacity-0 transition-opacity group-hover:opacity-100">
<CopyTextButton
value={JSON.stringify(
activeTab === 'request' ? webhookCall.requestBody : webhookCall.responseBody,
null,
2,
)}
onCopySuccess={() => toast({ title: t`Copied to clipboard` })}
/>
</div>
<pre className="bg-muted/50 border-border text-foreground overflow-x-auto rounded-lg border p-4 font-mono text-xs leading-relaxed">
{JSON.stringify(
activeTab === 'request' ? webhookCall.requestBody : webhookCall.responseBody,
null,
2,
)}
</pre>
</div>
{activeTab === 'response' && (
<div className="mt-6">
<h4 className="text-muted-foreground mb-3 text-xs font-semibold uppercase tracking-wider">
<Trans>Response Headers</Trans>
</h4>
<div className="border-border overflow-hidden rounded-lg border">
<table className="w-full text-left text-sm">
<tbody className="divide-border bg-muted/30 divide-y">
{Object.entries(webhookCall.responseHeaders as Record<string, string>).map(
([key, value]) => (
<tr key={key}>
<td className="text-muted-foreground border-border w-1/3 border-r px-4 py-2 font-mono text-xs">
{key}
</td>
<td className="text-foreground break-all px-4 py-2 font-mono text-xs">
{value as string}
</td>
</tr>
),
)}
</tbody>
</table>
</div>
</div>
)}
</div>
</div>
</SheetContent>
</Sheet>
);
},
);

View File

@ -1,4 +1,4 @@
import WebhookPage, { meta } from '../../t.$teamUrl+/settings.webhooks.$id'; import WebhookPage, { meta } from '../../t.$teamUrl+/settings.webhooks.$id._index';
export { meta }; export { meta };

View File

@ -0,0 +1,392 @@
import { useMemo, useState } from 'react';
import { useEffect } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import { WebhookCallStatus, WebhookTriggerEvents } from '@prisma/client';
import {
CheckCircle2Icon,
ChevronRightIcon,
PencilIcon,
TerminalIcon,
XCircleIcon,
} from 'lucide-react';
import { useNavigate } from 'react-router';
import { useLocation, useSearchParams } from 'react-router';
import { Link } from 'react-router';
import { z } from 'zod';
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import { ZUrlSearchParamsSchema } from '@documenso/lib/types/search-params';
import { toFriendlyWebhookEventName } from '@documenso/lib/universal/webhook/to-friendly-webhook-event-name';
import { trpc } from '@documenso/trpc/react';
import { Badge } from '@documenso/ui/primitives/badge';
import { Button } from '@documenso/ui/primitives/button';
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 { Input } from '@documenso/ui/primitives/input';
import { MultiSelectCombobox } from '@documenso/ui/primitives/multi-select-combobox';
import { Skeleton } from '@documenso/ui/primitives/skeleton';
import { SpinnerBox } from '@documenso/ui/primitives/spinner';
import { TableCell } from '@documenso/ui/primitives/table';
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { WebhookEditDialog } from '~/components/dialogs/webhook-edit-dialog';
import { WebhookTestDialog } from '~/components/dialogs/webhook-test-dialog';
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
import { SettingsHeader } from '~/components/general/settings-header';
import { WebhookLogsSheet } from '~/components/general/webhook-logs-sheet';
import { useCurrentTeam } from '~/providers/team';
import { appMetaTags } from '~/utils/meta';
import type { Route } from './+types/settings.webhooks.$id._index';
const WebhookSearchParamsSchema = ZUrlSearchParamsSchema.extend({
status: z.nativeEnum(WebhookCallStatus).optional(),
events: z.preprocess(
(value) => (typeof value === 'string' && value.length > 0 ? value.split(',') : []),
z.array(z.nativeEnum(WebhookTriggerEvents)).optional(),
),
});
export function meta() {
return appMetaTags('Webhooks');
}
export default function WebhookPage({ params }: Route.ComponentProps) {
const { t, i18n } = useLingui();
const { toast } = useToast();
const { pathname } = useLocation();
const [searchParams, setSearchParams] = useSearchParams();
const updateSearchParams = useUpdateSearchParams();
const team = useCurrentTeam();
const [searchQuery, setSearchQuery] = useState(() => searchParams?.get('query') ?? '');
const debouncedSearchQuery = useDebouncedValue(searchQuery, 500);
const parsedSearchParams = WebhookSearchParamsSchema.parse(
Object.fromEntries(searchParams ?? []),
);
const { data: webhook, isLoading } = trpc.webhook.getWebhookById.useQuery(
{
id: params.id,
},
{ enabled: !!params.id, retry: false },
);
const {
data,
isLoading: isLogsLoading,
isLoadingError: isLogsLoadingError,
} = trpc.webhook.calls.find.useQuery({
webhookId: params.id,
page: parsedSearchParams.page,
perPage: parsedSearchParams.perPage,
status: parsedSearchParams.status,
events: parsedSearchParams.events,
query: parsedSearchParams.query,
});
/**
* Handle debouncing the search query.
*/
useEffect(() => {
const params = new URLSearchParams(searchParams?.toString());
params.set('query', debouncedSearchQuery);
if (debouncedSearchQuery === '') {
params.delete('query');
}
// If nothing to change then do nothing.
if (params.toString() === searchParams?.toString()) {
return;
}
setSearchParams(params);
}, [debouncedSearchQuery, pathname, searchParams]);
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`Status`,
accessorKey: 'status',
cell: ({ row }) => (
<Badge variant={row.original.status === 'SUCCESS' ? 'default' : 'destructive'}>
{row.original.status === 'SUCCESS' ? (
<CheckCircle2Icon className="mr-2 h-4 w-4" />
) : (
<XCircleIcon className="mr-2 h-4 w-4" />
)}
{row.original.responseCode}
</Badge>
),
},
{
header: t`Event`,
accessorKey: 'event',
cell: ({ row }) => (
<div>
<p className="text-foreground text-sm font-semibold">
{toFriendlyWebhookEventName(row.original.event)}
</p>
<p className="text-muted-foreground text-xs">{row.original.id}</p>
</div>
),
},
{
header: t`Sent`,
accessorKey: 'createdAt',
cell: ({ row }) => (
<div className="flex items-center justify-between gap-2">
<p>
{i18n.date(row.original.createdAt, {
timeStyle: 'short',
dateStyle: 'short',
})}
</p>
<div className="data-state-selected:block opacity-0 transition-opacity group-hover:opacity-100">
<ChevronRightIcon className="h-4 w-4" />
</div>
</div>
),
},
] satisfies DataTableColumnDef<(typeof results)['data'][number]>[];
}, []);
const getTabHref = (value: string) => {
const params = new URLSearchParams(searchParams);
params.set('status', value);
if (value === '') {
params.delete('status');
}
if (params.has('page')) {
params.delete('page');
}
let path = pathname;
if (params.toString()) {
path += `?${params.toString()}`;
}
return path;
};
if (isLoading) {
return <SpinnerBox className="py-32" />;
}
// Todo: Update UI, currently out of place.
if (!webhook) {
return (
<GenericErrorLayout
errorCode={404}
errorCodeMap={{
404: {
heading: msg`Webhook not found`,
subHeading: msg`404 Webhook not found`,
message: msg`The webhook you are looking for may have been removed, renamed or may have never existed.`,
},
}}
primaryButton={
<Button asChild>
<Link to={`/t/${team.url}/settings/webhooks`}>
<Trans>Go back</Trans>
</Link>
</Button>
}
secondaryButton={null}
/>
);
}
return (
<div>
<SettingsHeader
title={
<div className="flex items-center gap-2">
<p>
<Trans>Webhook</Trans>
</p>
<Badge variant={webhook.enabled ? 'default' : 'secondary'}>
{webhook.enabled ? <Trans>Enabled</Trans> : <Trans>Disabled</Trans>}
</Badge>
</div>
}
subtitle={webhook.webhookUrl}
>
<div className="flex gap-2">
<WebhookTestDialog webhook={webhook}>
<Button variant="outline">
<TerminalIcon className="mr-2 h-4 w-4" />
<Trans>Test</Trans>
</Button>
</WebhookTestDialog>
<WebhookEditDialog
webhook={webhook}
trigger={
<Button>
<PencilIcon className="mr-2 h-4 w-4" />
<Trans>Edit</Trans>
</Button>
}
/>
</div>
</SettingsHeader>
<div className="mt-4">
<div className="mb-4 flex flex-row items-center justify-between gap-x-4">
<Input
defaultValue={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t`Search by ID`}
/>
<WebhookEventCombobox />
<Tabs value={parsedSearchParams.status || ''} className="flex-shrink-0">
<TabsList>
<TabsTrigger className="hover:text-foreground min-w-[60px]" value="" asChild>
<Link to={getTabHref('')}>
<Trans>All</Trans>
</Link>
</TabsTrigger>
<TabsTrigger className="hover:text-foreground min-w-[60px]" value="SUCCESS" asChild>
<Link to={getTabHref(WebhookCallStatus.SUCCESS)}>
<Trans>Success</Trans>
</Link>
</TabsTrigger>
<TabsTrigger className="hover:text-foreground min-w-[60px]" value="FAILED" asChild>
<Link to={getTabHref(WebhookCallStatus.FAILED)}>
<Trans>Failed</Trans>
</Link>
</TabsTrigger>
</TabsList>
</Tabs>
</div>
<DataTable
columns={columns}
data={results.data}
perPage={results.perPage}
currentPage={results.currentPage}
totalPages={results.totalPages}
onPaginationChange={onPaginationChange}
onRowClick={(row) =>
WebhookLogsSheet.call({
webhookCall: row,
})
}
rowClassName="cursor-pointer group"
error={{
enable: isLogsLoadingError,
}}
skeleton={{
enable: isLogsLoading,
rows: 3,
component: (
<>
<TableCell>
<Skeleton className="h-4 w-12 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-12 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-12 rounded-full" />
</TableCell>
</>
),
}}
>
{(table) =>
results.totalPages > 1 && (
<DataTablePagination additionalInformation="VisibleCount" table={table} />
)
}
</DataTable>
</div>
<WebhookLogsSheet.Root />
</div>
);
}
const WebhookEventCombobox = () => {
const { pathname } = useLocation();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const isMounted = useIsMounted();
const events = (searchParams?.get('events') ?? '').split(',').filter((value) => value !== '');
const comboBoxOptions = Object.values(WebhookTriggerEvents).map((event) => ({
label: toFriendlyWebhookEventName(event),
value: event,
}));
const onChange = (newEvents: string[]) => {
if (!pathname) {
return;
}
const params = new URLSearchParams(searchParams?.toString());
params.set('events', newEvents.join(','));
if (newEvents.length === 0) {
params.delete('events');
}
void navigate(`${pathname}?${params.toString()}`, { preventScrollReset: true });
};
return (
<MultiSelectCombobox
emptySelectionPlaceholder={
<p className="text-muted-foreground font-normal">
<Trans>
<span className="text-muted-foreground/70">Events:</span> All
</Trans>
</p>
}
enableClearAllButton={true}
inputPlaceholder={msg`Search`}
loading={!isMounted}
options={comboBoxOptions}
selectedValues={events}
onChange={onChange}
/>
);
};

View File

@ -1,263 +0,0 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { useForm } from 'react-hook-form';
import { useRevalidator } from 'react-router';
import { Link } from 'react-router';
import type { z } from 'zod';
import { trpc } from '@documenso/trpc/react';
import { ZEditWebhookRequestSchema } from '@documenso/trpc/server/webhook-router/schema';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { PasswordInput } from '@documenso/ui/primitives/password-input';
import { SpinnerBox } from '@documenso/ui/primitives/spinner';
import { Switch } from '@documenso/ui/primitives/switch';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { WebhookTestDialog } from '~/components/dialogs/webhook-test-dialog';
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
import { SettingsHeader } from '~/components/general/settings-header';
import { WebhookMultiSelectCombobox } from '~/components/general/webhook-multiselect-combobox';
import { useCurrentTeam } from '~/providers/team';
import { appMetaTags } from '~/utils/meta';
import type { Route } from './+types/settings.webhooks.$id';
const ZEditWebhookFormSchema = ZEditWebhookRequestSchema.omit({ id: true, teamId: true });
type TEditWebhookFormSchema = z.infer<typeof ZEditWebhookFormSchema>;
export function meta() {
return appMetaTags('Webhooks');
}
export default function WebhookPage({ params }: Route.ComponentProps) {
const { _ } = useLingui();
const { toast } = useToast();
const { revalidate } = useRevalidator();
const team = useCurrentTeam();
const { data: webhook, isLoading } = trpc.webhook.getWebhookById.useQuery(
{
id: params.id,
teamId: team.id,
},
{ enabled: !!params.id && !!team.id },
);
const { mutateAsync: updateWebhook } = trpc.webhook.editWebhook.useMutation();
const form = useForm<TEditWebhookFormSchema>({
resolver: zodResolver(ZEditWebhookFormSchema),
values: {
webhookUrl: webhook?.webhookUrl ?? '',
eventTriggers: webhook?.eventTriggers ?? [],
secret: webhook?.secret ?? '',
enabled: webhook?.enabled ?? true,
},
});
const onSubmit = async (data: TEditWebhookFormSchema) => {
try {
await updateWebhook({
id: params.id,
teamId: team.id,
...data,
});
toast({
title: _(msg`Webhook updated`),
description: _(msg`The webhook has been updated successfully.`),
duration: 5000,
});
await revalidate();
} catch (err) {
toast({
title: _(msg`Failed to update webhook`),
description: _(
msg`We encountered an error while updating the webhook. Please try again later.`,
),
variant: 'destructive',
});
}
};
if (isLoading) {
return <SpinnerBox className="py-32" />;
}
// Todo: Update UI, currently out of place.
if (!webhook) {
return (
<GenericErrorLayout
errorCode={404}
errorCodeMap={{
404: {
heading: msg`Webhook not found`,
subHeading: msg`404 Webhook not found`,
message: msg`The webhook you are looking for may have been removed, renamed or may have never existed.`,
},
}}
primaryButton={
<Button asChild>
<Link to={`/t/${team.url}/settings/webhooks`}>
<Trans>Go back</Trans>
</Link>
</Button>
}
secondaryButton={null}
/>
);
}
return (
<div className="max-w-2xl">
<SettingsHeader
title={_(msg`Edit webhook`)}
subtitle={_(msg`On this page, you can edit the webhook and its settings.`)}
/>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<fieldset className="flex h-full flex-col gap-y-6" disabled={form.formState.isSubmitting}>
<div className="flex flex-col-reverse gap-4 md:flex-row">
<FormField
control={form.control}
name="webhookUrl"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel required>Webhook URL</FormLabel>
<FormControl>
<Input className="bg-background" {...field} />
</FormControl>
<FormDescription>
<Trans>The URL for Documenso to send webhook events to.</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="enabled"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Enabled</Trans>
</FormLabel>
<div>
<FormControl>
<Switch
className="bg-background"
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</div>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="eventTriggers"
render={({ field: { onChange, value } }) => (
<FormItem className="flex flex-col gap-2">
<FormLabel required>
<Trans>Triggers</Trans>
</FormLabel>
<FormControl>
<WebhookMultiSelectCombobox
listValues={value}
onChange={(values: string[]) => {
onChange(values);
}}
/>
</FormControl>
<FormDescription>
<Trans>The events that will trigger a webhook to be sent to your URL.</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="secret"
render={({ field }) => (
<FormItem>
<FormLabel>Secret</FormLabel>
<FormControl>
<PasswordInput className="bg-background" {...field} value={field.value ?? ''} />
</FormControl>
<FormDescription>
<Trans>
A secret that will be sent to your URL so you can verify that the request has
been sent by Documenso.
</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end gap-4">
<Button type="submit" loading={form.formState.isSubmitting}>
<Trans>Update webhook</Trans>
</Button>
</div>
</fieldset>
</form>
</Form>
<Alert
className="mt-6 flex flex-col items-center justify-between gap-4 p-6 md:flex-row"
variant="neutral"
>
<div>
<AlertTitle>
<Trans>Test Webhook</Trans>
</AlertTitle>
<AlertDescription className="mr-2">
<Trans>
Send a test webhook with sample data to verify your integration is working correctly.
</Trans>
</AlertDescription>
</div>
<div className="flex-shrink-0">
<WebhookTestDialog webhook={webhook}>
<Button variant="outline" disabled={!webhook.enabled}>
<Trans>Test Webhook</Trans>
</Button>
</WebhookTestDialog>
</div>
</Alert>
</div>
);
}

View File

@ -1,7 +1,18 @@
import { useMemo } from 'react';
import { msg } from '@lingui/core/macro'; import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react'; import { Plural, useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import { Loader } from 'lucide-react'; import type { Webhook } from '@prisma/client';
import {
CheckCircle2Icon,
EditIcon,
Loader,
MoreHorizontalIcon,
ScrollTextIcon,
Trash2Icon,
XCircleIcon,
} from 'lucide-react';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { Link } from 'react-router'; import { Link } from 'react-router';
@ -10,9 +21,21 @@ import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
import { Badge } from '@documenso/ui/primitives/badge'; import { Badge } from '@documenso/ui/primitives/badge';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { DataTable, type DataTableColumnDef } from '@documenso/ui/primitives/data-table';
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
} from '@documenso/ui/primitives/dropdown-menu';
import { Skeleton } from '@documenso/ui/primitives/skeleton';
import { TableCell } from '@documenso/ui/primitives/table';
import { WebhookCreateDialog } from '~/components/dialogs/webhook-create-dialog'; import { WebhookCreateDialog } from '~/components/dialogs/webhook-create-dialog';
import { WebhookDeleteDialog } from '~/components/dialogs/webhook-delete-dialog'; import { WebhookDeleteDialog } from '~/components/dialogs/webhook-delete-dialog';
import { WebhookEditDialog } from '~/components/dialogs/webhook-edit-dialog';
import { SettingsHeader } from '~/components/general/settings-header'; import { SettingsHeader } from '~/components/general/settings-header';
import { useCurrentTeam } from '~/providers/team'; import { useCurrentTeam } from '~/providers/team';
import { appMetaTags } from '~/utils/meta'; import { appMetaTags } from '~/utils/meta';
@ -22,19 +45,72 @@ export function meta() {
} }
export default function WebhookPage() { export default function WebhookPage() {
const { _, i18n } = useLingui(); const { t, i18n } = useLingui();
const team = useCurrentTeam(); const team = useCurrentTeam();
const { data: webhooks, isLoading } = trpc.webhook.getTeamWebhooks.useQuery({ const { data, isLoading, isError } = trpc.webhook.getTeamWebhooks.useQuery();
teamId: team.id,
}); const results = {
data: data ?? [],
perPage: 0,
currentPage: 0,
totalPages: 0,
};
const columns = useMemo(() => {
return [
{
header: t`Webhook`,
cell: ({ row }) => (
<Link to={`/t/${team.url}/settings/webhooks/${row.original.id}`}>
<p className="text-muted-foreground text-xs">{row.original.id}</p>
<p
className="text-foreground max-w-sm truncate text-xs font-semibold"
title={row.original.webhookUrl}
>
{row.original.webhookUrl}
</p>
</Link>
),
},
{
header: t`Status`,
cell: ({ row }) => (
<Badge variant={row.original.enabled ? 'default' : 'neutral'} size="small">
{row.original.enabled ? <Trans>Enabled</Trans> : <Trans>Disabled</Trans>}
</Badge>
),
},
{
header: t`Listening to`,
cell: ({ row }) => (
<p
className="text-foreground"
title={row.original.eventTriggers
.map((event) => toFriendlyWebhookEventName(event))
.join(', ')}
>
<Plural value={row.original.eventTriggers.length} one="# Event" other="# Events" />
</p>
),
},
{
header: t`Created`,
cell: ({ row }) => i18n.date(row.original.createdAt),
},
{
header: t`Actions`,
cell: ({ row }) => <WebhookTableActionDropdown webhook={row.original} />,
},
] satisfies DataTableColumnDef<(typeof results)['data'][number]>[];
}, []);
return ( return (
<div> <div>
<SettingsHeader <SettingsHeader
title={_(msg`Webhooks`)} title={t`Webhooks`}
subtitle={_(msg`On this page, you can create new Webhooks and manage the existing ones.`)} subtitle={t`On this page, you can create new Webhooks and manage the existing ones.`}
> >
<WebhookCreateDialog /> <WebhookCreateDialog />
</SettingsHeader> </SettingsHeader>
@ -43,74 +119,95 @@ export default function WebhookPage() {
<Loader className="h-8 w-8 animate-spin text-gray-500" /> <Loader className="h-8 w-8 animate-spin text-gray-500" />
</div> </div>
)} )}
{webhooks && webhooks.length === 0 && (
// TODO: Perhaps add some illustrations here to make the page more engaging <DataTable
<div className="mb-4"> columns={columns}
<p className="text-muted-foreground mt-2 text-sm italic"> data={results.data}
perPage={results.perPage}
currentPage={results.currentPage}
totalPages={results.totalPages}
error={{
enable: isError,
}}
emptyState={
<div className="text-muted-foreground/60 flex h-60 flex-col items-center justify-center gap-y-4">
<p>
<Trans> <Trans>
You have no webhooks yet. Your webhooks will be shown here once you create them. You have no webhooks yet. Your webhooks will be shown here once you create them.
</Trans> </Trans>
</p> </p>
</div> </div>
)} }
{webhooks && webhooks.length > 0 && ( skeleton={{
<div className="mt-4 flex max-w-2xl flex-col gap-y-4"> enable: isLoading,
{webhooks?.map((webhook) => ( rows: 3,
<div component: (
key={webhook.id} <>
className={cn( <TableCell>
'border-border rounded-lg border p-4', <Skeleton className="h-4 w-24 rounded-full" />
!webhook.enabled && 'bg-muted/40', </TableCell>
)} <TableCell>
> <Skeleton className="h-4 w-8 rounded-full" />
<div className="flex flex-col gap-x-4 sm:flex-row sm:items-center sm:justify-between"> </TableCell>
<div> <TableCell>
<div className="truncate font-mono text-xs">{webhook.id}</div> <Skeleton className="h-4 w-12 rounded-full" />
</TableCell>
<div className="mt-1.5 flex items-center gap-2"> <TableCell>
<h5 <Skeleton className="h-4 w-12 rounded-full" />
className="max-w-[30rem] truncate text-sm sm:max-w-[18rem]" </TableCell>
title={webhook.webhookUrl} <TableCell>
> <Skeleton className="h-4 w-6 rounded-full" />
{webhook.webhookUrl} </TableCell>
</h5> </>
),
<Badge variant={webhook.enabled ? 'neutral' : 'warning'} size="small"> }}
{webhook.enabled ? <Trans>Enabled</Trans> : <Trans>Disabled</Trans>} />
</Badge>
</div>
<p className="text-muted-foreground mt-2 text-xs">
<Trans>
Listening to{' '}
{webhook.eventTriggers
.map((trigger) => toFriendlyWebhookEventName(trigger))
.join(', ')}
</Trans>
</p>
<p className="text-muted-foreground mt-2 text-xs">
<Trans>Created on {i18n.date(webhook.createdAt, DateTime.DATETIME_FULL)}</Trans>
</p>
</div>
<div className="mt-4 flex flex-shrink-0 gap-4 sm:mt-0">
<Button asChild variant="outline">
<Link to={`/t/${team.url}/settings/webhooks/${webhook.id}`}>
<Trans>Edit</Trans>
</Link>
</Button>
<WebhookDeleteDialog webhook={webhook}>
<Button variant="destructive">
<Trans>Delete</Trans>
</Button>
</WebhookDeleteDialog>
</div>
</div>
</div>
))}
</div>
)}
</div> </div>
); );
} }
const WebhookTableActionDropdown = ({ webhook }: { webhook: Webhook }) => {
const team = useCurrentTeam();
return (
<DropdownMenu>
<DropdownMenuTrigger data-testid="webhook-table-action-btn">
<MoreHorizontalIcon className="text-muted-foreground h-5 w-5" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" forceMount>
<DropdownMenuLabel>
<Trans>Action</Trans>
</DropdownMenuLabel>
<DropdownMenuItem asChild>
<Link to={`/t/${team.url}/settings/webhooks/${webhook.id}`}>
<ScrollTextIcon className="mr-2 h-4 w-4" />
<Trans>Logs</Trans>
</Link>
</DropdownMenuItem>
<WebhookEditDialog
webhook={webhook}
trigger={
<DropdownMenuItem asChild onSelect={(e) => e.preventDefault()}>
<div>
<EditIcon className="mr-2 h-4 w-4" />
<Trans>Edit</Trans>
</div>
</DropdownMenuItem>
}
/>
<WebhookDeleteDialog webhook={webhook}>
<DropdownMenuItem asChild onSelect={(e) => e.preventDefault()}>
<div>
<Trash2Icon className="mr-2 h-4 w-4" />
<Trans>Delete</Trans>
</div>
</DropdownMenuItem>
</WebhookDeleteDialog>
</DropdownMenuContent>
</DropdownMenu>
);
};

View File

@ -1,4 +1,3 @@
// Todo: [Webhooks] delete file after deployment.
import { handlerTriggerWebhooks } from '@documenso/lib/server-only/webhooks/trigger/handler'; import { handlerTriggerWebhooks } from '@documenso/lib/server-only/webhooks/trigger/handler';
import type { Route } from './+types/webhook.trigger'; import type { Route } from './+types/webhook.trigger';

View File

@ -0,0 +1,377 @@
import { expect, test } from '@playwright/test';
import { WebhookCallStatus, WebhookTriggerEvents } from '@prisma/client';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { prisma } from '@documenso/prisma';
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin, apiSignout } from '../fixtures/authentication';
import { expectTextToBeVisible } from '../fixtures/generic';
/**
* Helper function to seed a webhook directly in the database for testing.
*/
const seedWebhook = async ({
webhookUrl,
eventTriggers,
secret,
enabled,
userId,
teamId,
}: {
webhookUrl: string;
eventTriggers: WebhookTriggerEvents[];
secret?: string | null;
enabled?: boolean;
userId: number;
teamId: number;
}) => {
return await prisma.webhook.create({
data: {
webhookUrl,
eventTriggers,
secret: secret ?? null,
enabled: enabled ?? true,
userId,
teamId,
},
});
};
test('[WEBHOOKS]: create webhook', async ({ page }) => {
const { user, team } = await seedUser();
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/settings/webhooks`,
});
const webhookUrl = `https://example.com/webhook-${Date.now()}`;
// Click Create Webhook button
await page.getByRole('button', { name: 'Create Webhook' }).click();
// Fill in the form
await page.getByLabel('Webhook URL*').fill(webhookUrl);
// Select event trigger - click on the triggers field and select DOCUMENT_CREATED
await page.getByLabel('Triggers').click();
await page.waitForTimeout(200); // Wait for dropdown to open
await page.getByText('document.created').click();
// Click outside the triggers field to close the dropdown
await page.getByText('The URL for Documenso to send webhook events to.').click();
// Fill in the form
await page.getByLabel('Secret').fill('secret');
// Submit the form
await page.getByRole('button', { name: 'Create' }).click();
// Wait for success toast
await expectTextToBeVisible(page, 'Webhook created');
await expectTextToBeVisible(page, 'The webhook was successfully created.');
// Verify webhook appears in the list
await expect(page.getByText(webhookUrl)).toBeVisible();
// Directly check database
const dbWebhook = await prisma.webhook.findFirstOrThrow({
where: {
userId: user.id,
},
});
expect(dbWebhook?.eventTriggers).toEqual([WebhookTriggerEvents.DOCUMENT_CREATED]);
expect(dbWebhook?.secret).toBe('secret');
expect(dbWebhook?.enabled).toBe(true);
});
test('[WEBHOOKS]: view webhooks', async ({ page }) => {
const { user, team } = await seedUser();
const webhookUrl = `https://example.com/webhook-${Date.now()}`;
// Create a webhook via seeding
const webhook = await seedWebhook({
webhookUrl,
eventTriggers: [WebhookTriggerEvents.DOCUMENT_CREATED, WebhookTriggerEvents.DOCUMENT_SENT],
userId: user.id,
teamId: team.id,
enabled: true,
});
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/settings/webhooks`,
});
// Verify webhook is visible in the table
await expect(page.getByText(webhookUrl)).toBeVisible();
await expect(page.getByText('Enabled')).toBeVisible();
await expect(page.getByText('2 Events')).toBeVisible();
// Click on webhook to navigate to detail page
await page.getByText(webhookUrl).click();
// Verify detail page shows webhook information
await page.waitForURL(`/t/${team.url}/settings/webhooks/${webhook.id}`);
await expect(page.getByText(webhookUrl)).toBeVisible();
await expect(page.getByText('Enabled')).toBeVisible();
});
test('[WEBHOOKS]: delete webhook', async ({ page }) => {
const { user, team } = await seedUser();
const webhookUrl = `https://example.com/webhook-${Date.now()}`;
// Create a webhook via seeding
const webhook = await seedWebhook({
webhookUrl,
eventTriggers: [WebhookTriggerEvents.DOCUMENT_CREATED],
userId: user.id,
teamId: team.id,
});
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/settings/webhooks`,
});
// Verify webhook is visible
await expect(page.getByText(webhookUrl)).toBeVisible();
// Find the row with the webhook and click the action dropdown
const webhookRow = page.locator('tr', { hasText: webhookUrl });
await webhookRow.getByTestId('webhook-table-action-btn').click();
// Click Delete menu item
await page.getByRole('menuitem', { name: 'Delete' }).click();
// Fill in confirmation field
const deleteMessage = `delete ${webhookUrl}`;
// The label contains "Confirm by typing:" followed by the delete message
await page.getByLabel(/Confirm by typing/).fill(deleteMessage);
// Click delete button
await page.getByRole('button', { name: 'Delete' }).click();
// Wait for success toast
await expectTextToBeVisible(page, 'Webhook deleted');
await expectTextToBeVisible(page, 'The webhook has been successfully deleted.');
// Verify webhook is removed from the list
await expect(page.getByText(webhookUrl)).not.toBeVisible();
});
test('[WEBHOOKS]: update webhook', async ({ page }) => {
const { user, team } = await seedUser();
const originalWebhookUrl = `https://example.com/webhook-original-${Date.now()}`;
const updatedWebhookUrl = `https://example.com/webhook-updated-${Date.now()}`;
// Create a webhook via seeding with initial values
const webhook = await seedWebhook({
webhookUrl: originalWebhookUrl,
eventTriggers: [WebhookTriggerEvents.DOCUMENT_CREATED, WebhookTriggerEvents.DOCUMENT_SENT],
userId: user.id,
teamId: team.id,
enabled: true,
});
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/settings/webhooks`,
});
// Verify webhook is visible with original values
await expect(page.getByText(originalWebhookUrl)).toBeVisible();
await expect(page.getByText('Enabled')).toBeVisible();
await expect(page.getByText('2 Events')).toBeVisible();
// Find the row with the webhook and click the action dropdown
const webhookRow = page.locator('tr', { hasText: originalWebhookUrl });
await webhookRow.getByTestId('webhook-table-action-btn').click();
// Click Edit menu item
await page.getByRole('menuitem', { name: 'Edit' }).click();
// Wait for dialog to open
await page.waitForTimeout(200);
// Change the webhook URL
await page.getByLabel('Webhook URL').clear();
await page.getByLabel('Webhook URL').fill(updatedWebhookUrl);
// Disable the webhook (toggle the switch)
const enabledSwitch = page.getByLabel('Enabled');
const isChecked = await enabledSwitch.isChecked();
if (isChecked) {
await enabledSwitch.click();
}
// Change the event triggers - remove one existing event and add a new one
// The selected items are shown as badges with remove buttons
// Remove one of the existing events (DOCUMENT_SENT) by clicking its remove button
const removeButtons = page.locator('button[aria-label="Remove"]');
const removeButtonCount = await removeButtons.count();
// Remove the "DOCUMENT_SENT" event (this will remove one of the two)
if (removeButtonCount > 0) {
await removeButtons.nth(1).click();
await page.waitForTimeout(200);
}
// Add new event triggers
await page.getByLabel('Triggers').click();
await page.waitForTimeout(200);
// Select DOCUMENT_COMPLETED (this will be added to the remaining DOCUMENT_CREATED)
await page.getByText('document.completed').click();
await page.waitForTimeout(200);
// Click outside to close the dropdown
await page.getByText('The URL for Documenso to send webhook events to.').click();
// Submit the form
await page.getByRole('button', { name: 'Update' }).click();
// Wait for success toast
await expectTextToBeVisible(page, 'Webhook updated');
await expectTextToBeVisible(page, 'The webhook has been updated successfully.');
// Verify changes are reflected in the list
// The old URL should be gone and new URL should be visible
await expect(page.getByText(originalWebhookUrl)).not.toBeVisible();
await expect(page.getByText(updatedWebhookUrl)).toBeVisible();
// Verify webhook is disabled
await expect(page.getByText('Disabled')).toBeVisible();
// Verify event count is still 2 (one removed, one added - DOCUMENT_CREATED and DOCUMENT_COMPLETED)
await expect(page.getByText('2 Events')).toBeVisible();
// Check the database directly to verify
const dbWebhook = await prisma.webhook.findUnique({
where: {
id: webhook.id,
},
});
expect(dbWebhook?.eventTriggers).toEqual([
WebhookTriggerEvents.DOCUMENT_CREATED,
WebhookTriggerEvents.DOCUMENT_COMPLETED,
]);
expect(dbWebhook?.enabled).toBe(false);
expect(dbWebhook?.webhookUrl).toBe(updatedWebhookUrl);
expect(dbWebhook?.secret).toBe('');
});
test('[WEBHOOKS]: cannot see unrelated webhooks', async ({ page }) => {
// Create two separate users with teams
const user1Data = await seedUser();
const user2Data = await seedUser();
const webhookUrl1 = `https://example.com/webhook-team1-${Date.now()}`;
const webhookUrl2 = `https://example.com/webhook-team2-${Date.now()}`;
// Create webhooks for both teams with DOCUMENT_CREATED event
const webhook1 = await seedWebhook({
webhookUrl: webhookUrl1,
eventTriggers: [WebhookTriggerEvents.DOCUMENT_CREATED],
userId: user1Data.user.id,
teamId: user1Data.team.id,
enabled: true,
});
const webhook2 = await seedWebhook({
webhookUrl: webhookUrl2,
eventTriggers: [WebhookTriggerEvents.DOCUMENT_SENT],
userId: user2Data.user.id,
teamId: user2Data.team.id,
});
// Create a document on team1 to trigger the webhook
const document = await seedBlankDocument(user1Data.user, user1Data.team.id, {
createDocumentOptions: {
title: 'Test Document for Webhook',
},
});
// Create a webhook call for team1's webhook (simulating the webhook being triggered)
// Since webhooks are triggered via jobs which may not run in tests, we create the call directly
const webhookCall1 = await prisma.webhookCall.create({
data: {
webhookId: webhook1.id,
url: webhookUrl1,
event: WebhookTriggerEvents.DOCUMENT_CREATED,
status: WebhookCallStatus.SUCCESS,
responseCode: 200,
requestBody: {
event: WebhookTriggerEvents.DOCUMENT_CREATED,
payload: {
id: document.id,
title: document.title,
},
createdAt: new Date().toISOString(),
webhookEndpoint: webhookUrl1,
},
},
});
// Sign in as user1
await apiSignin({
page,
email: user1Data.user.email,
redirectPath: `/t/${user1Data.team.url}/settings/webhooks`,
});
// Verify user1 can see their webhook
await expect(page.getByText(webhookUrl1)).toBeVisible();
// Verify user1 cannot see user2's webhook
await expect(page.getByText(webhookUrl2)).not.toBeVisible();
// Navigate to team1's webhook logs page
await page.goto(
`${NEXT_PUBLIC_WEBAPP_URL()}/t/${user1Data.team.url}/settings/webhooks/${webhook1.id}`,
);
// Verify user1 can see their webhook logs
// The webhook call should be visible in the table
await expect(page.getByText(webhookCall1.id)).toBeVisible();
await expect(page.getByText('200')).toBeVisible(); // Response code
// Sign out and sign in as user2
await apiSignout({ page });
await apiSignin({
page,
email: user2Data.user.email,
redirectPath: `/t/${user2Data.team.url}/settings/webhooks`,
});
// Verify user2 can see their webhook
await expect(page.getByText(webhookUrl2)).toBeVisible();
// Verify user2 cannot see user1's webhook
await expect(page.getByText(webhookUrl1)).not.toBeVisible();
// Navigate to team2's webhook logs page
await page.goto(
`${NEXT_PUBLIC_WEBAPP_URL()}/t/${user2Data.team.url}/settings/webhooks/${webhook2.id}`,
);
// Verify user2 cannot see team1's webhook logs
// The webhook call from team1 should not be visible
await expect(page.getByText(webhookCall1.id)).not.toBeVisible();
// Attempt to access user1's webhook detail page directly via URL
await page.goto(
`${NEXT_PUBLIC_WEBAPP_URL()}/t/${user2Data.team.url}/settings/webhooks/${webhook1.id}`,
);
// Verify access is denied - should show error or redirect
// Based on the component, it shows a 404 error page
await expect(page.getByRole('heading', { name: 'Webhook not found' })).toBeVisible();
});

View File

@ -22,6 +22,7 @@ export const run = async ({
const { webhookUrl: url, secret } = webhook; const { webhookUrl: url, secret } = webhook;
await io.runTask('execute-webhook', async () => {
const payloadData = { const payloadData = {
event, event,
payload: data, payload: data,
@ -29,6 +30,7 @@ export const run = async ({
webhookEndpoint: url, webhookEndpoint: url,
}; };
// Note: This is duplicated in `resend-webhook-call.ts`.
const response = await fetch(url, { const response = await fetch(url, {
method: 'POST', method: 'POST',
body: JSON.stringify(payloadData), body: JSON.stringify(payloadData),
@ -69,4 +71,5 @@ export const run = async ({
success: response.ok, success: response.ok,
status: response.status, status: response.status,
}; };
});
}; };

View File

@ -13,7 +13,6 @@ export type HandlerTriggerWebhooksResponse =
error: string; error: string;
}; };
// Todo: [Webhooks] delete after deployment.
export const handlerTriggerWebhooks = async (req: Request) => { export const handlerTriggerWebhooks = async (req: Request) => {
const signature = req.headers.get('x-webhook-signature'); const signature = req.headers.get('x-webhook-signature');

View File

@ -1,6 +1,7 @@
import type { WebhookTriggerEvents } from '@prisma/client'; import type { WebhookTriggerEvents } from '@prisma/client';
import { jobs } from '../../../jobs/client'; import { NEXT_PRIVATE_INTERNAL_WEBAPP_URL } from '../../../constants/app';
import { sign } from '../../crypto/sign';
import { getAllWebhooksByEventTrigger } from '../get-all-webhooks-by-event-trigger'; import { getAllWebhooksByEventTrigger } from '../get-all-webhooks-by-event-trigger';
export type TriggerWebhookOptions = { export type TriggerWebhookOptions = {
@ -12,26 +13,35 @@ export type TriggerWebhookOptions = {
export const triggerWebhook = async ({ event, data, userId, teamId }: TriggerWebhookOptions) => { export const triggerWebhook = async ({ event, data, userId, teamId }: TriggerWebhookOptions) => {
try { try {
const body = {
event,
data,
userId,
teamId,
};
const registeredWebhooks = await getAllWebhooksByEventTrigger({ event, userId, teamId }); const registeredWebhooks = await getAllWebhooksByEventTrigger({ event, userId, teamId });
if (registeredWebhooks.length === 0) { if (registeredWebhooks.length === 0) {
return; return;
} }
await Promise.allSettled( const signature = sign(body);
registeredWebhooks.map(async (webhook) => {
await jobs.triggerJob({ await Promise.race([
name: 'internal.execute-webhook', fetch(`${NEXT_PRIVATE_INTERNAL_WEBAPP_URL()}/api/webhook/trigger`, {
payload: { method: 'POST',
event, headers: {
webhookId: webhook.id, 'content-type': 'application/json',
data, 'x-webhook-signature': signature,
}, },
}); body: JSON.stringify(body),
}), }),
); new Promise((_, reject) => {
setTimeout(() => reject(new Error('Request timeout')), 500);
}),
]).catch(() => null);
} catch (err) { } catch (err) {
console.error(err);
throw new Error(`Failed to trigger webhook`); throw new Error(`Failed to trigger webhook`);
} }
}; };

View File

@ -8,7 +8,7 @@ msgstr ""
"Language: pl\n" "Language: pl\n"
"Project-Id-Version: documenso-app\n" "Project-Id-Version: documenso-app\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2025-11-21 00:14\n" "PO-Revision-Date: 2025-11-20 02:32\n"
"Last-Translator: \n" "Last-Translator: \n"
"Language-Team: Polish\n" "Language-Team: Polish\n"
"Plural-Forms: nplurals=4; plural=(n==1 ? 0 : (n%10>=2 && n%10<=4) && (n%100<12 || n%100>14) ? 1 : n!=1 && (n%10>=0 && n%10<=1) || (n%10>=5 && n%10<=9) || (n%100>=12 && n%100<=14) ? 2 : 3);\n" "Plural-Forms: nplurals=4; plural=(n==1 ? 0 : (n%10>=2 && n%10<=4) && (n%100<12 || n%100>14) ? 1 : n!=1 && (n%10>=0 && n%10<=1) || (n%10>=5 && n%10<=9) || (n%100>=12 && n%100<=14) ? 2 : 3);\n"
@ -7584,7 +7584,7 @@ msgstr "Liczba podpisów"
#: packages/ui/components/document/envelope-recipient-field-tooltip.tsx #: packages/ui/components/document/envelope-recipient-field-tooltip.tsx
#: packages/ui/components/document/document-read-only-fields.tsx #: packages/ui/components/document/document-read-only-fields.tsx
msgid "Signed" msgid "Signed"
msgstr "Podpisano" msgstr "Podpisał"
#: apps/remix/app/components/dialogs/envelope-download-dialog.tsx #: apps/remix/app/components/dialogs/envelope-download-dialog.tsx
msgctxt "Signed document (adjective)" msgctxt "Signed document (adjective)"

View File

@ -457,10 +457,8 @@ export const templateRouter = router({
recipients, recipients,
distributeDocument, distributeDocument,
customDocumentDataId, customDocumentDataId,
folderId,
prefillFields, prefillFields,
override, folderId,
attachments,
} = input; } = input;
ctx.logger.info({ ctx.logger.info({
@ -497,8 +495,6 @@ export const templateRouter = router({
requestMetadata: ctx.metadata, requestMetadata: ctx.metadata,
folderId, folderId,
prefillFields, prefillFields,
override,
attachments,
}); });
if (distributeDocument) { if (distributeDocument) {

View File

@ -133,42 +133,12 @@ export const ZCreateDocumentFromTemplateRequestSchema = z.object({
'The ID of the folder to create the document in. If not provided, the document will be created in the root folder.', 'The ID of the folder to create the document in. If not provided, the document will be created in the root folder.',
) )
.optional(), .optional(),
prefillFields: z prefillFields: z
.array(ZFieldMetaPrefillFieldsSchema) .array(ZFieldMetaPrefillFieldsSchema)
.describe( .describe(
'The fields to prefill on the document before sending it out. Useful when you want to create a document from an existing template and pre-fill the fields with specific values.', 'The fields to prefill on the document before sending it out. Useful when you want to create a document from an existing template and pre-fill the fields with specific values.',
) )
.optional(), .optional(),
override: z
.object({
title: z.string().min(1).max(255).optional(),
subject: ZDocumentMetaSubjectSchema.optional(),
message: ZDocumentMetaMessageSchema.optional(),
timezone: ZDocumentMetaTimezoneSchema.optional(),
dateFormat: ZDocumentMetaDateFormatSchema.optional(),
redirectUrl: ZDocumentMetaRedirectUrlSchema.optional(),
distributionMethod: ZDocumentMetaDistributionMethodSchema.optional(),
emailSettings: ZDocumentEmailSettingsSchema.optional(),
language: ZDocumentMetaLanguageSchema.optional(),
typedSignatureEnabled: ZDocumentMetaTypedSignatureEnabledSchema.optional(),
uploadSignatureEnabled: ZDocumentMetaUploadSignatureEnabledSchema.optional(),
drawSignatureEnabled: ZDocumentMetaDrawSignatureEnabledSchema.optional(),
allowDictateNextSigner: z.boolean().optional(),
})
.describe('Override values from the template for the created document.')
.optional(),
attachments: z
.array(
z.object({
label: z.string().min(1, 'Label is required'),
data: z.string().url('Must be a valid URL'),
type: ZEnvelopeAttachmentTypeSchema.optional().default('link'),
}),
)
.optional(),
}); });
export const ZCreateDocumentFromTemplateResponseSchema = ZDocumentSchema; export const ZCreateDocumentFromTemplateResponseSchema = ZDocumentSchema;

View File

@ -0,0 +1,106 @@
import { Prisma, WebhookCallStatus, WebhookTriggerEvents } from '@prisma/client';
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { FindResultResponse } from '@documenso/lib/types/search-params';
import { buildTeamWhereQuery } from '@documenso/lib/utils/teams';
import { prisma } from '@documenso/prisma';
import { authenticatedProcedure } from '../trpc';
import {
ZFindWebhookCallsRequestSchema,
ZFindWebhookCallsResponseSchema,
} from './find-webhook-calls.types';
export const findWebhookCallsRoute = authenticatedProcedure
.input(ZFindWebhookCallsRequestSchema)
.output(ZFindWebhookCallsResponseSchema)
.query(async ({ input, ctx }) => {
const { webhookId, page, perPage, status, query, events } = input;
ctx.logger.info({
input: { webhookId, status },
});
return await findWebhookCalls({
userId: ctx.user.id,
teamId: ctx.teamId,
webhookId,
page,
perPage,
status,
query,
events,
});
});
type FindWebhookCallsOptions = {
userId: number;
teamId: number;
webhookId: string;
page?: number;
perPage?: number;
status?: WebhookCallStatus;
events?: WebhookTriggerEvents[];
query?: string;
};
export const findWebhookCalls = async ({
userId,
teamId,
webhookId,
page = 1,
perPage = 20,
events,
query = '',
status,
}: FindWebhookCallsOptions) => {
const webhook = await prisma.webhook.findFirst({
where: {
id: webhookId,
team: buildTeamWhereQuery({
teamId,
userId,
roles: TEAM_MEMBER_ROLE_PERMISSIONS_MAP.MANAGE_TEAM,
}),
},
});
if (!webhook) {
throw new AppError(AppErrorCode.NOT_FOUND);
}
const whereClause: Prisma.WebhookCallWhereInput = {
webhookId: webhook.id,
status,
id: query || undefined,
event:
events && events.length > 0
? {
in: events,
}
: undefined,
};
const [data, count] = await Promise.all([
prisma.webhookCall.findMany({
where: whereClause,
skip: Math.max(page - 1, 0) * perPage,
take: perPage,
orderBy: {
createdAt: 'desc',
},
}),
prisma.webhookCall.count({
where: whereClause,
}),
]);
return {
data,
count,
currentPage: Math.max(page, 1),
perPage,
totalPages: Math.ceil(count / perPage),
} satisfies FindResultResponse<typeof data>;
};

View File

@ -0,0 +1,37 @@
import { WebhookCallStatus, WebhookTriggerEvents } from '@prisma/client';
import { z } from 'zod';
import { ZFindResultResponse, ZFindSearchParamsSchema } from '@documenso/lib/types/search-params';
import WebhookCallSchema from '@documenso/prisma/generated/zod/modelSchema/WebhookCallSchema';
export const ZFindWebhookCallsRequestSchema = ZFindSearchParamsSchema.extend({
webhookId: z.string(),
status: z.nativeEnum(WebhookCallStatus).optional(),
events: z
.array(z.nativeEnum(WebhookTriggerEvents))
.optional()
.refine((arr) => !arr || new Set(arr).size === arr.length, {
message: 'Events must be unique',
}),
});
export const ZFindWebhookCallsResponseSchema = ZFindResultResponse.extend({
data: WebhookCallSchema.pick({
webhookId: true,
status: true,
event: true,
id: true,
url: true,
responseCode: true,
createdAt: true,
})
.extend({
requestBody: z.unknown(),
responseHeaders: z.unknown().nullable(),
responseBody: z.unknown().nullable(),
})
.array(),
});
export type TFindWebhookCallsRequest = z.infer<typeof ZFindWebhookCallsRequestSchema>;
export type TFindWebhookCallsResponse = z.infer<typeof ZFindWebhookCallsResponseSchema>;

View File

@ -0,0 +1,80 @@
import { Prisma, WebhookCallStatus, WebhookTriggerEvents } from '@prisma/client';
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { FindResultResponse } from '@documenso/lib/types/search-params';
import { buildTeamWhereQuery } from '@documenso/lib/utils/teams';
import { prisma } from '@documenso/prisma';
import { authenticatedProcedure } from '../trpc';
import {
ZResendWebhookCallRequestSchema,
ZResendWebhookCallResponseSchema,
} from './resend-webhook-call.types';
export const resendWebhookCallRoute = authenticatedProcedure
.input(ZResendWebhookCallRequestSchema)
.output(ZResendWebhookCallResponseSchema)
.mutation(async ({ input, ctx }) => {
const { teamId, user } = ctx;
const { webhookId, webhookCallId } = input;
ctx.logger.info({
input: { webhookId, webhookCallId },
});
const webhookCall = await prisma.webhookCall.findFirst({
where: {
id: webhookCallId,
webhook: {
id: webhookId,
team: buildTeamWhereQuery({
teamId,
userId: user.id,
roles: TEAM_MEMBER_ROLE_PERMISSIONS_MAP.MANAGE_TEAM,
}),
},
},
include: {
webhook: true,
},
});
if (!webhookCall) {
throw new AppError(AppErrorCode.NOT_FOUND);
}
const { webhook } = webhookCall;
// Note: This is duplicated in `execute-webhook.handler.ts`.
const response = await fetch(webhookCall.url, {
method: 'POST',
body: JSON.stringify(webhookCall.requestBody),
headers: {
'Content-Type': 'application/json',
'X-Documenso-Secret': webhook.secret ?? '',
},
});
const body = await response.text();
let responseBody: Prisma.InputJsonValue | Prisma.JsonNullValueInput = Prisma.JsonNull;
try {
responseBody = JSON.parse(body);
} catch (err) {
responseBody = body;
}
return await prisma.webhookCall.update({
where: {
id: webhookCall.id,
},
data: {
status: response.ok ? WebhookCallStatus.SUCCESS : WebhookCallStatus.FAILED,
responseCode: response.status,
responseBody,
responseHeaders: Object.fromEntries(response.headers.entries()),
},
});
});

View File

@ -0,0 +1,26 @@
import { WebhookCallStatus, WebhookTriggerEvents } from '@prisma/client';
import { z } from 'zod';
import WebhookCallSchema from '@documenso/prisma/generated/zod/modelSchema/WebhookCallSchema';
export const ZResendWebhookCallRequestSchema = z.object({
webhookId: z.string(),
webhookCallId: z.string(),
});
export const ZResendWebhookCallResponseSchema = WebhookCallSchema.pick({
webhookId: true,
status: true,
event: true,
id: true,
url: true,
responseCode: true,
createdAt: true,
}).extend({
requestBody: z.unknown(),
responseHeaders: z.unknown().nullable(),
responseBody: z.unknown().nullable(),
});
export type TResendWebhookRequest = z.infer<typeof ZResendWebhookCallRequestSchema>;
export type TResendWebhookResponse = z.infer<typeof ZResendWebhookCallResponseSchema>;

View File

@ -6,66 +6,61 @@ import { getWebhooksByTeamId } from '@documenso/lib/server-only/webhooks/get-web
import { triggerTestWebhook } from '@documenso/lib/server-only/webhooks/trigger-test-webhook'; import { triggerTestWebhook } from '@documenso/lib/server-only/webhooks/trigger-test-webhook';
import { authenticatedProcedure, router } from '../trpc'; import { authenticatedProcedure, router } from '../trpc';
import { findWebhookCallsRoute } from './find-webhook-calls';
import { resendWebhookCallRoute } from './resend-webhook-call';
import { import {
ZCreateWebhookRequestSchema, ZCreateWebhookRequestSchema,
ZDeleteWebhookRequestSchema, ZDeleteWebhookRequestSchema,
ZEditWebhookRequestSchema, ZEditWebhookRequestSchema,
ZGetTeamWebhooksRequestSchema,
ZGetWebhookByIdRequestSchema, ZGetWebhookByIdRequestSchema,
ZTriggerTestWebhookRequestSchema, ZTriggerTestWebhookRequestSchema,
} from './schema'; } from './schema';
export const webhookRouter = router({ export const webhookRouter = router({
getTeamWebhooks: authenticatedProcedure calls: {
.input(ZGetTeamWebhooksRequestSchema) find: findWebhookCallsRoute,
.query(async ({ ctx, input }) => { resend: resendWebhookCallRoute,
const { teamId } = input; },
getTeamWebhooks: authenticatedProcedure.query(async ({ ctx }) => {
ctx.logger.info({ ctx.logger.info({
input: { input: {
teamId, teamId: ctx.teamId,
}, },
}); });
return await getWebhooksByTeamId(teamId, ctx.user.id); return await getWebhooksByTeamId(ctx.teamId, ctx.user.id);
}), }),
getWebhookById: authenticatedProcedure getWebhookById: authenticatedProcedure
.input(ZGetWebhookByIdRequestSchema) .input(ZGetWebhookByIdRequestSchema)
.query(async ({ input, ctx }) => { .query(async ({ input, ctx }) => {
const { id, teamId } = input; const { id } = input;
ctx.logger.info({ ctx.logger.info({
input: { input: {
id, id,
teamId,
}, },
}); });
return await getWebhookById({ return await getWebhookById({
id, id,
userId: ctx.user.id, userId: ctx.user.id,
teamId, teamId: ctx.teamId,
}); });
}), }),
createWebhook: authenticatedProcedure createWebhook: authenticatedProcedure
.input(ZCreateWebhookRequestSchema) .input(ZCreateWebhookRequestSchema)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
const { enabled, eventTriggers, secret, webhookUrl, teamId } = input; const { enabled, eventTriggers, secret, webhookUrl } = input;
ctx.logger.info({
input: {
teamId,
},
});
return await createWebhook({ return await createWebhook({
enabled, enabled,
secret, secret,
webhookUrl, webhookUrl,
eventTriggers, eventTriggers,
teamId, teamId: ctx.teamId,
userId: ctx.user.id, userId: ctx.user.id,
}); });
}), }),
@ -73,18 +68,17 @@ export const webhookRouter = router({
deleteWebhook: authenticatedProcedure deleteWebhook: authenticatedProcedure
.input(ZDeleteWebhookRequestSchema) .input(ZDeleteWebhookRequestSchema)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
const { id, teamId } = input; const { id } = input;
ctx.logger.info({ ctx.logger.info({
input: { input: {
id, id,
teamId,
}, },
}); });
return await deleteWebhookById({ return await deleteWebhookById({
id, id,
teamId, teamId: ctx.teamId,
userId: ctx.user.id, userId: ctx.user.id,
}); });
}), }),
@ -92,12 +86,11 @@ export const webhookRouter = router({
editWebhook: authenticatedProcedure editWebhook: authenticatedProcedure
.input(ZEditWebhookRequestSchema) .input(ZEditWebhookRequestSchema)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
const { id, teamId, ...data } = input; const { id, ...data } = input;
ctx.logger.info({ ctx.logger.info({
input: { input: {
id, id,
teamId,
}, },
}); });
@ -105,20 +98,19 @@ export const webhookRouter = router({
id, id,
data, data,
userId: ctx.user.id, userId: ctx.user.id,
teamId, teamId: ctx.teamId,
}); });
}), }),
testWebhook: authenticatedProcedure testWebhook: authenticatedProcedure
.input(ZTriggerTestWebhookRequestSchema) .input(ZTriggerTestWebhookRequestSchema)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
const { id, event, teamId } = input; const { id, event } = input;
ctx.logger.info({ ctx.logger.info({
input: { input: {
id, id,
event, event,
teamId,
}, },
}); });
@ -126,7 +118,7 @@ export const webhookRouter = router({
id, id,
event, event,
userId: ctx.user.id, userId: ctx.user.id,
teamId, teamId: ctx.teamId,
}); });
}), }),
}); });

View File

@ -1,12 +1,6 @@
import { WebhookTriggerEvents } from '@prisma/client'; import { WebhookTriggerEvents } from '@prisma/client';
import { z } from 'zod'; import { z } from 'zod';
export const ZGetTeamWebhooksRequestSchema = z.object({
teamId: z.number(),
});
export type TGetTeamWebhooksRequestSchema = z.infer<typeof ZGetTeamWebhooksRequestSchema>;
export const ZCreateWebhookRequestSchema = z.object({ export const ZCreateWebhookRequestSchema = z.object({
webhookUrl: z.string().url(), webhookUrl: z.string().url(),
eventTriggers: z eventTriggers: z
@ -14,14 +8,12 @@ export const ZCreateWebhookRequestSchema = z.object({
.min(1, { message: 'At least one event trigger is required' }), .min(1, { message: 'At least one event trigger is required' }),
secret: z.string().nullable(), secret: z.string().nullable(),
enabled: z.boolean(), enabled: z.boolean(),
teamId: z.number(),
}); });
export type TCreateWebhookFormSchema = z.infer<typeof ZCreateWebhookRequestSchema>; export type TCreateWebhookFormSchema = z.infer<typeof ZCreateWebhookRequestSchema>;
export const ZGetWebhookByIdRequestSchema = z.object({ export const ZGetWebhookByIdRequestSchema = z.object({
id: z.string(), id: z.string(),
teamId: z.number(),
}); });
export type TGetWebhookByIdRequestSchema = z.infer<typeof ZGetWebhookByIdRequestSchema>; export type TGetWebhookByIdRequestSchema = z.infer<typeof ZGetWebhookByIdRequestSchema>;
@ -34,7 +26,6 @@ export type TEditWebhookRequestSchema = z.infer<typeof ZEditWebhookRequestSchema
export const ZDeleteWebhookRequestSchema = z.object({ export const ZDeleteWebhookRequestSchema = z.object({
id: z.string(), id: z.string(),
teamId: z.number(),
}); });
export type TDeleteWebhookRequestSchema = z.infer<typeof ZDeleteWebhookRequestSchema>; export type TDeleteWebhookRequestSchema = z.infer<typeof ZDeleteWebhookRequestSchema>;
@ -42,7 +33,6 @@ export type TDeleteWebhookRequestSchema = z.infer<typeof ZDeleteWebhookRequestSc
export const ZTriggerTestWebhookRequestSchema = z.object({ export const ZTriggerTestWebhookRequestSchema = z.object({
id: z.string(), id: z.string(),
event: z.nativeEnum(WebhookTriggerEvents), event: z.nativeEnum(WebhookTriggerEvents),
teamId: z.number(),
}); });
export type TTriggerTestWebhookRequestSchema = z.infer<typeof ZTriggerTestWebhookRequestSchema>; export type TTriggerTestWebhookRequestSchema = z.infer<typeof ZTriggerTestWebhookRequestSchema>;

View File

@ -21,6 +21,8 @@ export interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[]; columns: ColumnDef<TData, TValue>[];
columnVisibility?: VisibilityState; columnVisibility?: VisibilityState;
data: TData[]; data: TData[];
onRowClick?: (row: TData) => void;
rowClassName?: string;
perPage?: number; perPage?: number;
currentPage?: number; currentPage?: number;
totalPages?: number; totalPages?: number;
@ -52,6 +54,8 @@ export function DataTable<TData, TValue>({
hasFilters, hasFilters,
onClearFilters, onClearFilters,
onPaginationChange, onPaginationChange,
onRowClick,
rowClassName,
children, children,
emptyState, emptyState,
}: DataTableProps<TData, TValue>) { }: DataTableProps<TData, TValue>) {
@ -116,7 +120,12 @@ export function DataTable<TData, TValue>({
<TableBody> <TableBody>
{table.getRowModel().rows?.length ? ( {table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => ( table.getRowModel().rows.map((row) => (
<TableRow key={row.id} data-state={row.getIsSelected() && 'selected'}> <TableRow
key={row.id}
data-state={row.getIsSelected() && 'selected'}
className={rowClassName}
onClick={() => onRowClick?.(row.original)}
>
{row.getVisibleCells().map((cell) => ( {row.getVisibleCells().map((cell) => (
<TableCell <TableCell
key={cell.id} key={cell.id}