mirror of
https://github.com/documenso/documenso.git
synced 2025-11-13 08:13:56 +10:00
feat: more webhook functionality
This commit is contained in:
162
apps/web/src/app/(dashboard)/settings/webhooks/[id]/page.tsx
Normal file
162
apps/web/src/app/(dashboard)/settings/webhooks/[id]/page.tsx
Normal file
@ -0,0 +1,162 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
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 { 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 } = 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."
|
||||||
|
/>
|
||||||
|
<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>
|
||||||
|
<Input 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,5 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
import { Zap } from 'lucide-react';
|
import { Zap } from 'lucide-react';
|
||||||
import { ToggleLeft, ToggleRight } from 'lucide-react';
|
import { ToggleLeft, ToggleRight } from 'lucide-react';
|
||||||
|
|
||||||
@ -22,7 +24,7 @@ export default function WebhookPage() {
|
|||||||
<CreateWebhookDialog />
|
<CreateWebhookDialog />
|
||||||
</SettingsHeader>
|
</SettingsHeader>
|
||||||
|
|
||||||
{webhooks?.length === 0 && (
|
{webhooks && webhooks.length === 0 && (
|
||||||
// TODO: Perhaps add some illustrations here to make the page more engaging
|
// TODO: Perhaps add some illustrations here to make the page more engaging
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<p className="text-muted-foreground mt-2 text-sm italic">
|
<p className="text-muted-foreground mt-2 text-sm italic">
|
||||||
@ -31,7 +33,7 @@ export default function WebhookPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{webhooks?.length > 0 && (
|
{webhooks && webhooks.length > 0 && (
|
||||||
<div className="mt-4 flex max-w-xl flex-col gap-y-4">
|
<div className="mt-4 flex max-w-xl flex-col gap-y-4">
|
||||||
{webhooks?.map((webhook) => (
|
{webhooks?.map((webhook) => (
|
||||||
<div key={webhook.id} className="border-border rounded-lg border p-4">
|
<div key={webhook.id} className="border-border rounded-lg border p-4">
|
||||||
@ -41,9 +43,9 @@ export default function WebhookPage() {
|
|||||||
<p className="text-muted-foreground">{webhook.webhookUrl}</p>
|
<p className="text-muted-foreground">{webhook.webhookUrl}</p>
|
||||||
<h4 className="mt-4 text-lg font-semibold">Event triggers</h4>
|
<h4 className="mt-4 text-lg font-semibold">Event triggers</h4>
|
||||||
{webhook.eventTriggers.map((trigger, index) => (
|
{webhook.eventTriggers.map((trigger, index) => (
|
||||||
<p key={index} className="text-muted-foreground flex flex-row items-center">
|
<span key={index} className="text-muted-foreground flex flex-row items-center">
|
||||||
<Zap className="mr-1 h-4 w-4 fill-yellow-400 stroke-yellow-600" /> {trigger}
|
<Zap className="mr-1 h-4 w-4" /> {trigger}
|
||||||
</p>
|
</span>
|
||||||
))}
|
))}
|
||||||
{webhook.enabled ? (
|
{webhook.enabled ? (
|
||||||
<h4 className="mt-4 flex items-center gap-2 text-lg">
|
<h4 className="mt-4 flex items-center gap-2 text-lg">
|
||||||
@ -57,8 +59,8 @@ export default function WebhookPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col-reverse space-y-2 space-y-reverse sm:flex-row sm:justify-end sm:space-x-2 sm:space-y-0">
|
<div className="flex flex-col-reverse space-y-2 space-y-reverse sm:flex-row sm:justify-end sm:space-x-2 sm:space-y-0">
|
||||||
<Button variant="secondary" className="">
|
<Button asChild variant="outline">
|
||||||
Edit
|
<Link href={`/settings/webhooks/${webhook.id}`}>Edit</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<DeleteWebhookDialog webhook={webhook}>
|
<DeleteWebhookDialog webhook={webhook}>
|
||||||
<Button variant="destructive">Delete</Button>
|
<Button variant="destructive">Delete</Button>
|
||||||
|
|||||||
@ -14,6 +14,8 @@ import {
|
|||||||
} from '@documenso/ui/primitives/command';
|
} from '@documenso/ui/primitives/command';
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover';
|
import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover';
|
||||||
|
|
||||||
|
import { truncateTitle } from '~/helpers/truncate-title';
|
||||||
|
|
||||||
type ComboboxProps = {
|
type ComboboxProps = {
|
||||||
listValues: string[];
|
listValues: string[];
|
||||||
onChange: (_values: string[]) => void;
|
onChange: (_values: string[]) => void;
|
||||||
@ -53,13 +55,13 @@ const MultiSelectCombobox = ({ listValues, onChange }: ComboboxProps) => {
|
|||||||
aria-expanded={isOpen}
|
aria-expanded={isOpen}
|
||||||
className="w-[200px] justify-between"
|
className="w-[200px] justify-between"
|
||||||
>
|
>
|
||||||
{selectedValues.length > 0 ? selectedValues.join(', ') : 'Select values...'}
|
{selectedValues.length > 0 ? selectedValues.length + ' selected...' : 'Select values...'}
|
||||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="z-9999 w-[200px] p-0">
|
<PopoverContent className="z-9999 w-[200px] p-0">
|
||||||
<Command>
|
<Command>
|
||||||
<CommandInput placeholder={selectedValues.join(', ')} />
|
<CommandInput placeholder={truncateTitle(selectedValues.join(', '), 15)} />
|
||||||
<CommandEmpty>No value found.</CommandEmpty>
|
<CommandEmpty>No value found.</CommandEmpty>
|
||||||
<CommandGroup>
|
<CommandGroup>
|
||||||
{allEvents.map((value: string, i: number) => (
|
{allEvents.map((value: string, i: 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
@ -2,11 +2,17 @@ import { TRPCError } from '@trpc/server';
|
|||||||
|
|
||||||
import { createWebhook } from '@documenso/lib/server-only/webhooks/create-webhook';
|
import { createWebhook } from '@documenso/lib/server-only/webhooks/create-webhook';
|
||||||
import { deleteWebhookById } from '@documenso/lib/server-only/webhooks/delete-webhook-by-id';
|
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 { getWebhooksByUserId } from '@documenso/lib/server-only/webhooks/get-webhooks-by-user-id';
|
||||||
|
|
||||||
import { authenticatedProcedure, router } from '../trpc';
|
import { authenticatedProcedure, router } from '../trpc';
|
||||||
import { ZCreateWebhookFormSchema } from './schema';
|
import {
|
||||||
import { ZDeleteWebhookSchema } from './schema';
|
ZCreateWebhookFormSchema,
|
||||||
|
ZDeleteWebhookMutationSchema,
|
||||||
|
ZEditWebhookMutationSchema,
|
||||||
|
ZGetWebhookByIdQuerySchema,
|
||||||
|
} from './schema';
|
||||||
|
|
||||||
export const webhookRouter = router({
|
export const webhookRouter = router({
|
||||||
getWebhooks: authenticatedProcedure.query(async ({ ctx }) => {
|
getWebhooks: authenticatedProcedure.query(async ({ ctx }) => {
|
||||||
@ -19,6 +25,24 @@ export const webhookRouter = router({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
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
|
createWebhook: authenticatedProcedure
|
||||||
.input(ZCreateWebhookFormSchema)
|
.input(ZCreateWebhookFormSchema)
|
||||||
.mutation(async ({ input, ctx }) => {
|
.mutation(async ({ input, ctx }) => {
|
||||||
@ -35,7 +59,7 @@ export const webhookRouter = router({
|
|||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
deleteWebhook: authenticatedProcedure
|
deleteWebhook: authenticatedProcedure
|
||||||
.input(ZDeleteWebhookSchema)
|
.input(ZDeleteWebhookMutationSchema)
|
||||||
.mutation(async ({ input, ctx }) => {
|
.mutation(async ({ input, ctx }) => {
|
||||||
try {
|
try {
|
||||||
const { id } = input;
|
const { id } = input;
|
||||||
@ -51,4 +75,22 @@ export const webhookRouter = router({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
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.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@ -11,10 +11,22 @@ export const ZCreateWebhookFormSchema = z.object({
|
|||||||
enabled: z.boolean(),
|
enabled: z.boolean(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const ZDeleteWebhookSchema = z.object({
|
export const ZGetWebhookByIdQuerySchema = z.object({
|
||||||
|
id: z.number(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ZEditWebhookMutationSchema = ZCreateWebhookFormSchema.extend({
|
||||||
|
id: z.number(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ZDeleteWebhookMutationSchema = z.object({
|
||||||
id: z.number(),
|
id: z.number(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type TCreateWebhookFormSchema = z.infer<typeof ZCreateWebhookFormSchema>;
|
export type TCreateWebhookFormSchema = z.infer<typeof ZCreateWebhookFormSchema>;
|
||||||
|
|
||||||
export type TDeleteWebhookSchema = z.infer<typeof ZDeleteWebhookSchema>;
|
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