mirror of
https://github.com/documenso/documenso.git
synced 2025-11-13 00:03:33 +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';
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
import { Zap } from 'lucide-react';
|
||||
import { ToggleLeft, ToggleRight } from 'lucide-react';
|
||||
|
||||
@ -22,7 +24,7 @@ export default function WebhookPage() {
|
||||
<CreateWebhookDialog />
|
||||
</SettingsHeader>
|
||||
|
||||
{webhooks?.length === 0 && (
|
||||
{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">
|
||||
@ -31,7 +33,7 @@ export default function WebhookPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{webhooks?.length > 0 && (
|
||||
{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">
|
||||
@ -41,9 +43,9 @@ export default function WebhookPage() {
|
||||
<p className="text-muted-foreground">{webhook.webhookUrl}</p>
|
||||
<h4 className="mt-4 text-lg font-semibold">Event triggers</h4>
|
||||
{webhook.eventTriggers.map((trigger, index) => (
|
||||
<p 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}
|
||||
</p>
|
||||
<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">
|
||||
@ -57,8 +59,8 @@ export default function WebhookPage() {
|
||||
</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">
|
||||
<Button variant="secondary" className="">
|
||||
Edit
|
||||
<Button asChild variant="outline">
|
||||
<Link href={`/settings/webhooks/${webhook.id}`}>Edit</Link>
|
||||
</Button>
|
||||
<DeleteWebhookDialog webhook={webhook}>
|
||||
<Button variant="destructive">Delete</Button>
|
||||
|
||||
@ -14,6 +14,8 @@ import {
|
||||
} 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;
|
||||
@ -53,13 +55,13 @@ const MultiSelectCombobox = ({ listValues, onChange }: ComboboxProps) => {
|
||||
aria-expanded={isOpen}
|
||||
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" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="z-9999 w-[200px] p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder={selectedValues.join(', ')} />
|
||||
<CommandInput placeholder={truncateTitle(selectedValues.join(', '), 15)} />
|
||||
<CommandEmpty>No value found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{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 { 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 } from './schema';
|
||||
import { ZDeleteWebhookSchema } from './schema';
|
||||
import {
|
||||
ZCreateWebhookFormSchema,
|
||||
ZDeleteWebhookMutationSchema,
|
||||
ZEditWebhookMutationSchema,
|
||||
ZGetWebhookByIdQuerySchema,
|
||||
} from './schema';
|
||||
|
||||
export const webhookRouter = router({
|
||||
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
|
||||
.input(ZCreateWebhookFormSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
@ -35,7 +59,7 @@ export const webhookRouter = router({
|
||||
}
|
||||
}),
|
||||
deleteWebhook: authenticatedProcedure
|
||||
.input(ZDeleteWebhookSchema)
|
||||
.input(ZDeleteWebhookMutationSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
try {
|
||||
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(),
|
||||
});
|
||||
|
||||
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(),
|
||||
});
|
||||
|
||||
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