mirror of
https://github.com/documenso/documenso.git
synced 2025-11-24 05:32:12 +10:00
chore: merged webhooks
This commit is contained in:
169
apps/web/src/app/(dashboard)/settings/webhooks/[id]/page.tsx
Normal file
169
apps/web/src/app/(dashboard)/settings/webhooks/[id]/page.tsx
Normal file
@ -0,0 +1,169 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { Loader } from 'lucide-react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import type { z } from 'zod';
|
||||||
|
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { ZEditWebhookMutationSchema } from '@documenso/trpc/server/webhook-router/schema';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
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 { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
|
||||||
|
import { MultiSelectCombobox } from '~/components/(dashboard)/settings/webhooks/multiselect-combobox';
|
||||||
|
|
||||||
|
const ZEditWebhookFormSchema = ZEditWebhookMutationSchema.omit({ id: true });
|
||||||
|
|
||||||
|
type TEditWebhookFormSchema = z.infer<typeof ZEditWebhookFormSchema>;
|
||||||
|
|
||||||
|
export type WebhookPageOptions = {
|
||||||
|
params: {
|
||||||
|
id: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function WebhookPage({ params }: WebhookPageOptions) {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const { data: webhook, isLoading } = trpc.webhook.getWebhookById.useQuery(
|
||||||
|
{
|
||||||
|
id: Number(params.id),
|
||||||
|
},
|
||||||
|
{ enabled: !!params.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: Number(params.id),
|
||||||
|
...data,
|
||||||
|
});
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Webhook updated',
|
||||||
|
description: 'The webhook has been updated successfully.',
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
router.refresh();
|
||||||
|
} catch (err) {
|
||||||
|
toast({
|
||||||
|
title: 'Failed to update webhook',
|
||||||
|
description: 'We encountered an error while updating the webhook. Please try again later.',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<SettingsHeader
|
||||||
|
title="Edit webhook"
|
||||||
|
subtitle="On this page, you can edit the webhook and its settings."
|
||||||
|
/>
|
||||||
|
{isLoading && (
|
||||||
|
<div className="absolute inset-0 z-50 flex items-center justify-center bg-white/50">
|
||||||
|
<Loader className="h-8 w-8 animate-spin text-gray-500" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||||
|
<fieldset className="flex h-full flex-col gap-y-6" disabled={form.formState.isSubmitting}>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="webhookUrl"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel htmlFor="webhookUrl">Webhook URL</FormLabel>
|
||||||
|
<Input {...field} id="webhookUrl" type="text" />
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="eventTriggers"
|
||||||
|
render={({ field: { onChange, value } }) => (
|
||||||
|
<FormItem className="flex flex-col">
|
||||||
|
<FormLabel required>Event triggers</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<MultiSelectCombobox
|
||||||
|
listValues={value}
|
||||||
|
onChange={(values: string[]) => {
|
||||||
|
onChange(values);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="secret"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Secret</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<PasswordInput className="bg-background" {...field} value={field.value ?? ''} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="enabled"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex items-center gap-x-2">
|
||||||
|
<FormLabel className="mt-2">Active</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Switch
|
||||||
|
className="bg-background"
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="mt-4">
|
||||||
|
<Button type="submit" loading={form.formState.isSubmitting}>
|
||||||
|
Update webhook
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
82
apps/web/src/app/(dashboard)/settings/webhooks/page.tsx
Normal file
82
apps/web/src/app/(dashboard)/settings/webhooks/page.tsx
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
import { Zap } from 'lucide-react';
|
||||||
|
import { ToggleLeft, ToggleRight } from 'lucide-react';
|
||||||
|
import { Loader } from 'lucide-react';
|
||||||
|
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
|
||||||
|
import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
|
||||||
|
import { CreateWebhookDialog } from '~/components/(dashboard)/settings/webhooks/create-webhook-dialog';
|
||||||
|
import { DeleteWebhookDialog } from '~/components/(dashboard)/settings/webhooks/delete-webhook-dialog';
|
||||||
|
|
||||||
|
export default function WebhookPage() {
|
||||||
|
const { data: webhooks, isLoading } = trpc.webhook.getWebhooks.useQuery();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<SettingsHeader
|
||||||
|
title="Webhooks"
|
||||||
|
subtitle="On this page, you can create new Webhooks and manage the existing ones."
|
||||||
|
>
|
||||||
|
<CreateWebhookDialog />
|
||||||
|
</SettingsHeader>
|
||||||
|
|
||||||
|
{isLoading && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center bg-white/50">
|
||||||
|
<Loader className="h-8 w-8 animate-spin text-gray-500" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{webhooks && webhooks.length === 0 && (
|
||||||
|
// TODO: Perhaps add some illustrations here to make the page more engaging
|
||||||
|
<div className="mb-4">
|
||||||
|
<p className="text-muted-foreground mt-2 text-sm italic">
|
||||||
|
You have no webhooks yet. Your webhooks will be shown here once you create them.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{webhooks && webhooks.length > 0 && (
|
||||||
|
<div className="mt-4 flex max-w-xl flex-col gap-y-4">
|
||||||
|
{webhooks?.map((webhook) => (
|
||||||
|
<div key={webhook.id} className="border-border rounded-lg border p-4">
|
||||||
|
<div className="flex items-center justify-between gap-x-4">
|
||||||
|
<div>
|
||||||
|
<h4 className="text-lg font-semibold">Webhook URL</h4>
|
||||||
|
<p className="text-muted-foreground">{webhook.webhookUrl}</p>
|
||||||
|
<h4 className="mt-4 text-lg font-semibold">Event triggers</h4>
|
||||||
|
{webhook.eventTriggers.map((trigger, index) => (
|
||||||
|
<span key={index} className="text-muted-foreground flex flex-row items-center">
|
||||||
|
<Zap className="mr-1 h-4 w-4" /> {trigger}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{webhook.enabled ? (
|
||||||
|
<h4 className="mt-4 flex items-center gap-2 text-lg">
|
||||||
|
Active <ToggleRight className="h-6 w-6 fill-green-200 stroke-green-400" />
|
||||||
|
</h4>
|
||||||
|
) : (
|
||||||
|
<h4 className="mt-4 flex items-center gap-2 text-lg">
|
||||||
|
Inactive <ToggleLeft className="h-6 w-6 fill-slate-200 stroke-slate-400" />
|
||||||
|
</h4>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-6 flex flex-col-reverse space-y-2 space-y-reverse sm:mt-0 sm:flex-row sm:justify-end sm:space-x-2 sm:space-y-0">
|
||||||
|
<Button asChild variant="outline">
|
||||||
|
<Link href={`/settings/webhooks/${webhook.id}`}>Edit</Link>
|
||||||
|
</Button>
|
||||||
|
<DeleteWebhookDialog webhook={webhook}>
|
||||||
|
<Button variant="destructive">Delete</Button>
|
||||||
|
</DeleteWebhookDialog>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -5,7 +5,7 @@ import type { HTMLAttributes } from 'react';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
|
|
||||||
import { Braces, CreditCard, Lock, User, Users } from 'lucide-react';
|
import { Braces, CreditCard, Lock, User, Users, Webhook } from 'lucide-react';
|
||||||
|
|
||||||
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
@ -51,6 +51,19 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
|
|||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<Link href="/settings/webhooks">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className={cn(
|
||||||
|
'w-full justify-start',
|
||||||
|
pathname?.startsWith('/settings/webhooks') && 'bg-secondary',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Webhook className="mr-2 h-5 w-5" />
|
||||||
|
Webhooks
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
|
||||||
<Link href="/settings/security">
|
<Link href="/settings/security">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import type { HTMLAttributes } from 'react';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
|
|
||||||
import { Braces, CreditCard, Lock, User, Users } from 'lucide-react';
|
import { Braces, CreditCard, Lock, User, Users, Webhook } from 'lucide-react';
|
||||||
|
|
||||||
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
@ -54,6 +54,19 @@ export const MobileNav = ({ className, ...props }: MobileNavProps) => {
|
|||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<Link href="/settings/webhooks">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className={cn(
|
||||||
|
'w-full justify-start',
|
||||||
|
pathname?.startsWith('/settings/webhooks') && 'bg-secondary',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Webhook className="mr-2 h-5 w-5" />
|
||||||
|
Webhooks
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
|
||||||
<Link href="/settings/security">
|
<Link href="/settings/security">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|||||||
@ -0,0 +1,192 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
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 { ZCreateWebhookFormSchema } from '@documenso/trpc/server/webhook-router/schema';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from '@documenso/ui/primitives/dialog';
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
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 { MultiSelectCombobox } from './multiselect-combobox';
|
||||||
|
|
||||||
|
type TCreateWebhookFormSchema = z.infer<typeof ZCreateWebhookFormSchema>;
|
||||||
|
|
||||||
|
export type CreateWebhookDialogProps = {
|
||||||
|
trigger?: React.ReactNode;
|
||||||
|
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
||||||
|
|
||||||
|
export const CreateWebhookDialog = ({ trigger, ...props }: CreateWebhookDialogProps) => {
|
||||||
|
const router = useRouter();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
const form = useForm<TCreateWebhookFormSchema>({
|
||||||
|
resolver: zodResolver(ZCreateWebhookFormSchema),
|
||||||
|
values: {
|
||||||
|
webhookUrl: '',
|
||||||
|
eventTriggers: [],
|
||||||
|
secret: '',
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { mutateAsync: createWebhook } = trpc.webhook.createWebhook.useMutation();
|
||||||
|
|
||||||
|
const onSubmit = async (values: TCreateWebhookFormSchema) => {
|
||||||
|
try {
|
||||||
|
await createWebhook(values);
|
||||||
|
|
||||||
|
setOpen(false);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Webhook created',
|
||||||
|
description: 'The webhook was successfully created.',
|
||||||
|
});
|
||||||
|
|
||||||
|
form.reset();
|
||||||
|
|
||||||
|
router.refresh();
|
||||||
|
} catch (err) {
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: 'An error occurred while creating the webhook. Please try again.',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
|
||||||
|
{trigger ?? <Button className="flex-shrink-0">Create Webhook</Button>}
|
||||||
|
</DialogTrigger>
|
||||||
|
|
||||||
|
<DialogContent position="center">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Create webhook</DialogTitle>
|
||||||
|
<DialogDescription>On this page, you can create a new webhook.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||||
|
<fieldset
|
||||||
|
className="flex h-full flex-col space-y-4"
|
||||||
|
disabled={form.formState.isSubmitting}
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="webhookUrl"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel required>Webhook URL</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input className="bg-background" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="eventTriggers"
|
||||||
|
render={({ field: { onChange, value } }) => (
|
||||||
|
<FormItem className="flex flex-col gap-2">
|
||||||
|
<FormLabel required>Event triggers</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<MultiSelectCombobox
|
||||||
|
listValues={value}
|
||||||
|
onChange={(values: string[]) => {
|
||||||
|
onChange(values);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="secret"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Secret</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<PasswordInput
|
||||||
|
className="bg-background"
|
||||||
|
{...field}
|
||||||
|
value={field.value ?? ''}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="enabled"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex items-center gap-2">
|
||||||
|
<FormLabel className="mt-2">Active</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Switch
|
||||||
|
className="bg-background"
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<div className="flex w-full flex-nowrap gap-4">
|
||||||
|
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" loading={form.formState.isSubmitting}>
|
||||||
|
Create
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogFooter>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,167 @@
|
|||||||
|
'use effect';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import type { Webhook } from '@documenso/prisma/client';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from '@documenso/ui/primitives/dialog';
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from '@documenso/ui/primitives/form/form';
|
||||||
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
export type DeleteWebhookDialogProps = {
|
||||||
|
webhook: Pick<Webhook, 'id' | 'webhookUrl'>;
|
||||||
|
onDelete?: () => void;
|
||||||
|
children: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DeleteWebhookDialog = ({ webhook, children }: DeleteWebhookDialogProps) => {
|
||||||
|
const router = useRouter();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
const deleteMessage = `delete ${webhook.webhookUrl}`;
|
||||||
|
|
||||||
|
const ZDeleteWebhookFormSchema = z.object({
|
||||||
|
webhookUrl: z.literal(deleteMessage, {
|
||||||
|
errorMap: () => ({ message: `You must enter '${deleteMessage}' to proceed` }),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
type TDeleteWebhookFormSchema = z.infer<typeof ZDeleteWebhookFormSchema>;
|
||||||
|
|
||||||
|
const { mutateAsync: deleteWebhook } = trpc.webhook.deleteWebhook.useMutation();
|
||||||
|
|
||||||
|
const form = useForm<TDeleteWebhookFormSchema>({
|
||||||
|
resolver: zodResolver(ZDeleteWebhookFormSchema),
|
||||||
|
values: {
|
||||||
|
webhookUrl: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = async () => {
|
||||||
|
try {
|
||||||
|
await deleteWebhook({ id: webhook.id });
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Webhook deleted',
|
||||||
|
duration: 5000,
|
||||||
|
description: 'The webhook has been successfully deleted.',
|
||||||
|
});
|
||||||
|
|
||||||
|
setOpen(false);
|
||||||
|
|
||||||
|
router.refresh();
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: 'An unknown error occurred',
|
||||||
|
variant: 'destructive',
|
||||||
|
duration: 5000,
|
||||||
|
description:
|
||||||
|
'We encountered an unknown error while attempting to delete it. Please try again later.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
form.reset();
|
||||||
|
}
|
||||||
|
}, [open, form]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
{children ?? (
|
||||||
|
<Button className="mr-4" variant="destructive">
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DialogTrigger>
|
||||||
|
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Delete Webhook</DialogTitle>
|
||||||
|
|
||||||
|
<DialogDescription>
|
||||||
|
Please note that this action is irreversible. Once confirmed, your webhook will be
|
||||||
|
permanently deleted.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||||
|
<fieldset
|
||||||
|
className="flex h-full flex-col space-y-4"
|
||||||
|
disabled={form.formState.isSubmitting}
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="webhookUrl"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
Confirm by typing:{' '}
|
||||||
|
<span className="font-sm text-destructive font-semibold">
|
||||||
|
{deleteMessage}
|
||||||
|
</span>
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input className="bg-background" type="text" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<div className="flex w-full flex-nowrap gap-4">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
className="flex-1"
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="destructive"
|
||||||
|
className="flex-1"
|
||||||
|
disabled={!form.formState.isValid}
|
||||||
|
loading={form.formState.isSubmitting}
|
||||||
|
>
|
||||||
|
I'm sure! Delete it
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogFooter>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,85 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
import { WebhookTriggerEvents } from '@prisma/client/';
|
||||||
|
import { Check, ChevronsUpDown } from 'lucide-react';
|
||||||
|
|
||||||
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import {
|
||||||
|
Command,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandInput,
|
||||||
|
CommandItem,
|
||||||
|
} from '@documenso/ui/primitives/command';
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover';
|
||||||
|
|
||||||
|
import { truncateTitle } from '~/helpers/truncate-title';
|
||||||
|
|
||||||
|
type ComboboxProps = {
|
||||||
|
listValues: string[];
|
||||||
|
onChange: (_values: string[]) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MultiSelectCombobox = ({ listValues, onChange }: ComboboxProps) => {
|
||||||
|
const [isOpen, setIsOpen] = React.useState(false);
|
||||||
|
const [selectedValues, setSelectedValues] = React.useState<string[]>([]);
|
||||||
|
|
||||||
|
const triggerEvents = Object.values(WebhookTriggerEvents);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
setSelectedValues(listValues);
|
||||||
|
}, [listValues]);
|
||||||
|
|
||||||
|
const allEvents = [...new Set([...triggerEvents, ...selectedValues])];
|
||||||
|
|
||||||
|
const handleSelect = (currentValue: string) => {
|
||||||
|
let newSelectedValues;
|
||||||
|
if (selectedValues.includes(currentValue)) {
|
||||||
|
newSelectedValues = selectedValues.filter((value) => value !== currentValue);
|
||||||
|
} else {
|
||||||
|
newSelectedValues = [...selectedValues, currentValue];
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedValues(newSelectedValues);
|
||||||
|
onChange(newSelectedValues);
|
||||||
|
setIsOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover open={isOpen} onOpenChange={setIsOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={isOpen}
|
||||||
|
className="w-[200px] justify-between"
|
||||||
|
>
|
||||||
|
{selectedValues.length > 0 ? selectedValues.length + ' selected...' : 'Select values...'}
|
||||||
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="z-9999 w-[200px] p-0">
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder={truncateTitle(selectedValues.join(', '), 15)} />
|
||||||
|
<CommandEmpty>No value found.</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{allEvents.map((value: string, i: number) => (
|
||||||
|
<CommandItem key={i} onSelect={() => handleSelect(value)}>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
'mr-2 h-4 w-4',
|
||||||
|
selectedValues.includes(value) ? 'opacity-100' : 'opacity-0',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{value}
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { MultiSelectCombobox };
|
||||||
0
apps/web/src/components/forms/webhook.tsx
Normal file
0
apps/web/src/components/forms/webhook.tsx
Normal file
@ -54,5 +54,8 @@
|
|||||||
"next-contentlayer": {
|
"next-contentlayer": {
|
||||||
"next": "14.0.3"
|
"next": "14.0.3"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"next-runtime-env": "^3.2.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,7 +5,9 @@ import type { RequestMetadata } from '@documenso/lib/universal/extract-request-m
|
|||||||
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
|
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
import { DocumentStatus, SigningStatus } from '@documenso/prisma/client';
|
import { DocumentStatus, SigningStatus } from '@documenso/prisma/client';
|
||||||
|
import { WebhookTriggerEvents } from '@documenso/prisma/client';
|
||||||
|
|
||||||
|
import { triggerWebhook } from '../../universal/trigger-webhook';
|
||||||
import { sealDocument } from './seal-document';
|
import { sealDocument } from './seal-document';
|
||||||
import { sendPendingEmail } from './send-pending-email';
|
import { sendPendingEmail } from './send-pending-email';
|
||||||
|
|
||||||
@ -15,14 +17,8 @@ export type CompleteDocumentWithTokenOptions = {
|
|||||||
requestMetadata?: RequestMetadata;
|
requestMetadata?: RequestMetadata;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const completeDocumentWithToken = async ({
|
const getDocument = async ({ token, documentId }: CompleteDocumentWithTokenOptions) => {
|
||||||
token,
|
return await prisma.document.findFirstOrThrow({
|
||||||
documentId,
|
|
||||||
requestMetadata,
|
|
||||||
}: CompleteDocumentWithTokenOptions) => {
|
|
||||||
'use server';
|
|
||||||
|
|
||||||
const document = await prisma.document.findFirstOrThrow({
|
|
||||||
where: {
|
where: {
|
||||||
id: documentId,
|
id: documentId,
|
||||||
Recipient: {
|
Recipient: {
|
||||||
@ -39,6 +35,16 @@ export const completeDocumentWithToken = async ({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const completeDocumentWithToken = async ({
|
||||||
|
token,
|
||||||
|
documentId,
|
||||||
|
requestMetadata,
|
||||||
|
}: CompleteDocumentWithTokenOptions) => {
|
||||||
|
'use server';
|
||||||
|
|
||||||
|
const document = await getDocument({ token, documentId });
|
||||||
|
|
||||||
if (document.status === DocumentStatus.COMPLETED) {
|
if (document.status === DocumentStatus.COMPLETED) {
|
||||||
throw new Error(`Document ${document.id} has already been completed`);
|
throw new Error(`Document ${document.id} has already been completed`);
|
||||||
@ -124,4 +130,11 @@ export const completeDocumentWithToken = async ({
|
|||||||
if (documents.count > 0) {
|
if (documents.count > 0) {
|
||||||
await sealDocument({ documentId: document.id, requestMetadata });
|
await sealDocument({ documentId: document.id, requestMetadata });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const updatedDocument = await getDocument({ token, documentId });
|
||||||
|
|
||||||
|
await triggerWebhook({
|
||||||
|
eventTrigger: WebhookTriggerEvents.DOCUMENT_SIGNED,
|
||||||
|
documentData: updatedDocument,
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@ -5,6 +5,9 @@ import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-log
|
|||||||
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||||
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
|
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
|
import { WebhookTriggerEvents } from '@documenso/prisma/client';
|
||||||
|
|
||||||
|
import { triggerWebhook } from '../../universal/trigger-webhook';
|
||||||
|
|
||||||
export type CreateDocumentOptions = {
|
export type CreateDocumentOptions = {
|
||||||
title: string;
|
title: string;
|
||||||
@ -63,6 +66,11 @@ export const createDocument = async ({
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await triggerWebhook({
|
||||||
|
eventTrigger: WebhookTriggerEvents.DOCUMENT_CREATED,
|
||||||
|
documentData: document,
|
||||||
|
});
|
||||||
|
|
||||||
return document;
|
return document;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@ -16,9 +16,8 @@ import { prisma } from '@documenso/prisma';
|
|||||||
import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client';
|
import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client';
|
||||||
import type { Prisma } from '@documenso/prisma/client';
|
import type { Prisma } from '@documenso/prisma/client';
|
||||||
|
|
||||||
import { getDocumentWhereInput } from './get-document-by-id';
|
|
||||||
|
|
||||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
|
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
|
||||||
|
import { getDocumentWhereInput } from './get-document-by-id';
|
||||||
|
|
||||||
export type ResendDocumentOptions = {
|
export type ResendDocumentOptions = {
|
||||||
documentId: number;
|
documentId: number;
|
||||||
|
|||||||
17
packages/lib/server-only/user/get-user-webhooks.ts
Normal file
17
packages/lib/server-only/user/get-user-webhooks.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
|
export interface GetUserWebhooksByIdOptions {
|
||||||
|
id: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getUserWebhooksById = async ({ id }: GetUserWebhooksByIdOptions) => {
|
||||||
|
return await prisma.user.findFirstOrThrow({
|
||||||
|
where: {
|
||||||
|
id,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
email: true,
|
||||||
|
Webhooks: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
28
packages/lib/server-only/webhooks/create-webhook.ts
Normal file
28
packages/lib/server-only/webhooks/create-webhook.ts
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
import type { WebhookTriggerEvents } from '@documenso/prisma/client';
|
||||||
|
|
||||||
|
export interface CreateWebhookOptions {
|
||||||
|
webhookUrl: string;
|
||||||
|
eventTriggers: WebhookTriggerEvents[];
|
||||||
|
secret: string | null;
|
||||||
|
enabled: boolean;
|
||||||
|
userId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createWebhook = async ({
|
||||||
|
webhookUrl,
|
||||||
|
eventTriggers,
|
||||||
|
secret,
|
||||||
|
enabled,
|
||||||
|
userId,
|
||||||
|
}: CreateWebhookOptions) => {
|
||||||
|
return await prisma.webhook.create({
|
||||||
|
data: {
|
||||||
|
webhookUrl,
|
||||||
|
eventTriggers,
|
||||||
|
secret,
|
||||||
|
enabled,
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
15
packages/lib/server-only/webhooks/delete-webhook-by-id.ts
Normal file
15
packages/lib/server-only/webhooks/delete-webhook-by-id.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
|
export type DeleteWebhookByIdOptions = {
|
||||||
|
id: number;
|
||||||
|
userId: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteWebhookById = async ({ id, userId }: DeleteWebhookByIdOptions) => {
|
||||||
|
return await prisma.webhook.delete({
|
||||||
|
where: {
|
||||||
|
id,
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
21
packages/lib/server-only/webhooks/edit-webhook.ts
Normal file
21
packages/lib/server-only/webhooks/edit-webhook.ts
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import type { Prisma } from '@prisma/client';
|
||||||
|
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
|
export type EditWebhookOptions = {
|
||||||
|
id: number;
|
||||||
|
data: Prisma.WebhookUpdateInput;
|
||||||
|
userId: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const editWebhook = async ({ id, data, userId }: EditWebhookOptions) => {
|
||||||
|
return await prisma.webhook.update({
|
||||||
|
where: {
|
||||||
|
id,
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
...data,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
17
packages/lib/server-only/webhooks/get-all-webhooks.ts
Normal file
17
packages/lib/server-only/webhooks/get-all-webhooks.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
import type { WebhookTriggerEvents } from '@documenso/prisma/client';
|
||||||
|
|
||||||
|
export type GetAllWebhooksOptions = {
|
||||||
|
eventTrigger: WebhookTriggerEvents;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getAllWebhooks = async ({ eventTrigger }: GetAllWebhooksOptions) => {
|
||||||
|
return prisma.webhook.findMany({
|
||||||
|
where: {
|
||||||
|
eventTriggers: {
|
||||||
|
has: eventTrigger,
|
||||||
|
},
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
15
packages/lib/server-only/webhooks/get-webhook-by-id.ts
Normal file
15
packages/lib/server-only/webhooks/get-webhook-by-id.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
|
export type GetWebhookByIdOptions = {
|
||||||
|
id: number;
|
||||||
|
userId: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getWebhookById = async ({ id, userId }: GetWebhookByIdOptions) => {
|
||||||
|
return await prisma.webhook.findFirstOrThrow({
|
||||||
|
where: {
|
||||||
|
id,
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
@ -0,0 +1,9 @@
|
|||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
|
export const getWebhooksByUserId = async (userId: number) => {
|
||||||
|
return await prisma.webhook.findMany({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
39
packages/lib/universal/post-webhook-payload.ts
Normal file
39
packages/lib/universal/post-webhook-payload.ts
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import type { Document, Webhook } from '@documenso/prisma/client';
|
||||||
|
|
||||||
|
export type PostWebhookPayloadOptions = {
|
||||||
|
webhookData: Pick<Webhook, 'webhookUrl' | 'secret' | 'eventTriggers'>;
|
||||||
|
documentData: Document;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const postWebhookPayload = async ({
|
||||||
|
webhookData,
|
||||||
|
documentData,
|
||||||
|
}: PostWebhookPayloadOptions) => {
|
||||||
|
const { webhookUrl, secret } = webhookData;
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
event: webhookData.eventTriggers.toString(),
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
webhookEndpoint: webhookUrl,
|
||||||
|
payload: documentData,
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await fetch(webhookUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Documenso-Secret': secret ?? '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Webhook failed with the status code ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText,
|
||||||
|
message: 'Webhook sent successfully',
|
||||||
|
};
|
||||||
|
};
|
||||||
30
packages/lib/universal/trigger-webhook.ts
Normal file
30
packages/lib/universal/trigger-webhook.ts
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import type { Document, WebhookTriggerEvents } from '@documenso/prisma/client';
|
||||||
|
|
||||||
|
import { getAllWebhooks } from '../server-only/webhooks/get-all-webhooks';
|
||||||
|
import { postWebhookPayload } from './post-webhook-payload';
|
||||||
|
|
||||||
|
export type TriggerWebhookOptions = {
|
||||||
|
eventTrigger: WebhookTriggerEvents;
|
||||||
|
documentData: Document;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const triggerWebhook = async ({ eventTrigger, documentData }: TriggerWebhookOptions) => {
|
||||||
|
try {
|
||||||
|
const allWebhooks = await getAllWebhooks({ eventTrigger });
|
||||||
|
|
||||||
|
const webhookPromises = allWebhooks.map((webhook) => {
|
||||||
|
const { webhookUrl, secret } = webhook;
|
||||||
|
|
||||||
|
postWebhookPayload({
|
||||||
|
webhookData: { webhookUrl, secret, eventTriggers: [eventTrigger] },
|
||||||
|
documentData,
|
||||||
|
}).catch((_err) => {
|
||||||
|
throw new Error(`Failed to send webhook to ${webhookUrl}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return Promise.all(webhookPromises);
|
||||||
|
} catch (err) {
|
||||||
|
throw new Error(`Failed to trigger webhook`);
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -0,0 +1,19 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "WebhookTriggerEvents" AS ENUM ('DOCUMENT_CREATED', 'DOCUMENT_SIGNED');
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Webhook" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"webhookUrl" TEXT NOT NULL,
|
||||||
|
"eventTriggers" "WebhookTriggerEvents"[],
|
||||||
|
"secret" TEXT,
|
||||||
|
"enabled" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"userId" INTEGER NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Webhook_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Webhook" ADD CONSTRAINT "Webhook_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@ -47,6 +47,7 @@ model User {
|
|||||||
ApiToken ApiToken[]
|
ApiToken ApiToken[]
|
||||||
Template Template[]
|
Template Template[]
|
||||||
securityAuditLogs UserSecurityAuditLog[]
|
securityAuditLogs UserSecurityAuditLog[]
|
||||||
|
Webhooks Webhook[]
|
||||||
|
|
||||||
@@index([email])
|
@@index([email])
|
||||||
}
|
}
|
||||||
@ -109,6 +110,23 @@ model ApiToken {
|
|||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum WebhookTriggerEvents {
|
||||||
|
DOCUMENT_CREATED
|
||||||
|
DOCUMENT_SIGNED
|
||||||
|
}
|
||||||
|
|
||||||
|
model Webhook {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
webhookUrl String
|
||||||
|
eventTriggers WebhookTriggerEvents[]
|
||||||
|
secret String?
|
||||||
|
enabled Boolean @default(true)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @default(now()) @updatedAt
|
||||||
|
userId Int
|
||||||
|
User User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
}
|
||||||
|
|
||||||
enum SubscriptionStatus {
|
enum SubscriptionStatus {
|
||||||
ACTIVE
|
ACTIVE
|
||||||
PAST_DUE
|
PAST_DUE
|
||||||
|
|||||||
@ -11,6 +11,9 @@ module.exports = {
|
|||||||
sans: ['var(--font-sans)', ...fontFamily.sans],
|
sans: ['var(--font-sans)', ...fontFamily.sans],
|
||||||
signature: ['var(--font-signature)'],
|
signature: ['var(--font-signature)'],
|
||||||
},
|
},
|
||||||
|
zIndex: {
|
||||||
|
9999: '9999',
|
||||||
|
},
|
||||||
colors: {
|
colors: {
|
||||||
border: 'hsl(var(--border))',
|
border: 'hsl(var(--border))',
|
||||||
input: 'hsl(var(--input))',
|
input: 'hsl(var(--input))',
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import { teamRouter } from './team-router/router';
|
|||||||
import { templateRouter } from './template-router/router';
|
import { templateRouter } from './template-router/router';
|
||||||
import { router } from './trpc';
|
import { router } from './trpc';
|
||||||
import { twoFactorAuthenticationRouter } from './two-factor-authentication-router/router';
|
import { twoFactorAuthenticationRouter } from './two-factor-authentication-router/router';
|
||||||
|
import { webhookRouter } from './webhook-router/router';
|
||||||
|
|
||||||
export const appRouter = router({
|
export const appRouter = router({
|
||||||
auth: authRouter,
|
auth: authRouter,
|
||||||
@ -26,6 +27,7 @@ export const appRouter = router({
|
|||||||
singleplayer: singleplayerRouter,
|
singleplayer: singleplayerRouter,
|
||||||
team: teamRouter,
|
team: teamRouter,
|
||||||
template: templateRouter,
|
template: templateRouter,
|
||||||
|
webhook: webhookRouter,
|
||||||
twoFactorAuthentication: twoFactorAuthenticationRouter,
|
twoFactorAuthentication: twoFactorAuthenticationRouter,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
96
packages/trpc/server/webhook-router/router.ts
Normal file
96
packages/trpc/server/webhook-router/router.ts
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
import { TRPCError } from '@trpc/server';
|
||||||
|
|
||||||
|
import { createWebhook } from '@documenso/lib/server-only/webhooks/create-webhook';
|
||||||
|
import { deleteWebhookById } from '@documenso/lib/server-only/webhooks/delete-webhook-by-id';
|
||||||
|
import { editWebhook } from '@documenso/lib/server-only/webhooks/edit-webhook';
|
||||||
|
import { getWebhookById } from '@documenso/lib/server-only/webhooks/get-webhook-by-id';
|
||||||
|
import { getWebhooksByUserId } from '@documenso/lib/server-only/webhooks/get-webhooks-by-user-id';
|
||||||
|
|
||||||
|
import { authenticatedProcedure, router } from '../trpc';
|
||||||
|
import {
|
||||||
|
ZCreateWebhookFormSchema,
|
||||||
|
ZDeleteWebhookMutationSchema,
|
||||||
|
ZEditWebhookMutationSchema,
|
||||||
|
ZGetWebhookByIdQuerySchema,
|
||||||
|
} from './schema';
|
||||||
|
|
||||||
|
export const webhookRouter = router({
|
||||||
|
getWebhooks: authenticatedProcedure.query(async ({ ctx }) => {
|
||||||
|
try {
|
||||||
|
return await getWebhooksByUserId(ctx.user.id);
|
||||||
|
} catch (err) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
message: 'We were unable to fetch your webhooks. Please try again later.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
getWebhookById: authenticatedProcedure
|
||||||
|
.input(ZGetWebhookByIdQuerySchema)
|
||||||
|
.query(async ({ input, ctx }) => {
|
||||||
|
try {
|
||||||
|
const { id } = input;
|
||||||
|
|
||||||
|
return await getWebhookById({
|
||||||
|
id,
|
||||||
|
userId: ctx.user.id,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
message: 'We were unable to fetch your webhook. Please try again later.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
createWebhook: authenticatedProcedure
|
||||||
|
.input(ZCreateWebhookFormSchema)
|
||||||
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
try {
|
||||||
|
return await createWebhook({
|
||||||
|
...input,
|
||||||
|
userId: ctx.user.id,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
message: 'We were unable to create this webhook. Please try again later.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
deleteWebhook: authenticatedProcedure
|
||||||
|
.input(ZDeleteWebhookMutationSchema)
|
||||||
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
try {
|
||||||
|
const { id } = input;
|
||||||
|
|
||||||
|
return await deleteWebhookById({
|
||||||
|
id,
|
||||||
|
userId: ctx.user.id,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
message: 'We were unable to create this webhook. Please try again later.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
editWebhook: authenticatedProcedure
|
||||||
|
.input(ZEditWebhookMutationSchema)
|
||||||
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
try {
|
||||||
|
const { id } = input;
|
||||||
|
|
||||||
|
return await editWebhook({
|
||||||
|
id,
|
||||||
|
data: input,
|
||||||
|
userId: ctx.user.id,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
message: 'We were unable to create this webhook. Please try again later.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
});
|
||||||
32
packages/trpc/server/webhook-router/schema.ts
Normal file
32
packages/trpc/server/webhook-router/schema.ts
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { WebhookTriggerEvents } from '@documenso/prisma/client';
|
||||||
|
|
||||||
|
export const ZCreateWebhookFormSchema = z.object({
|
||||||
|
webhookUrl: z.string().url(),
|
||||||
|
eventTriggers: z
|
||||||
|
.array(z.nativeEnum(WebhookTriggerEvents))
|
||||||
|
.min(1, { message: 'At least one event trigger is required' }),
|
||||||
|
secret: z.string().nullable(),
|
||||||
|
enabled: z.boolean(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ZGetWebhookByIdQuerySchema = z.object({
|
||||||
|
id: z.number(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ZEditWebhookMutationSchema = ZCreateWebhookFormSchema.extend({
|
||||||
|
id: z.number(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ZDeleteWebhookMutationSchema = z.object({
|
||||||
|
id: z.number(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TCreateWebhookFormSchema = z.infer<typeof ZCreateWebhookFormSchema>;
|
||||||
|
|
||||||
|
export type TGetWebhookByIdQuerySchema = z.infer<typeof ZGetWebhookByIdQuerySchema>;
|
||||||
|
|
||||||
|
export type TDeleteWebhookMutationSchema = z.infer<typeof ZDeleteWebhookMutationSchema>;
|
||||||
|
|
||||||
|
export type TEditWebhookMutationSchema = z.infer<typeof ZEditWebhookMutationSchema>;
|
||||||
Reference in New Issue
Block a user