feat: support team webhooks

This commit is contained in:
Mythie
2024-02-27 16:56:32 +11:00
parent 7dd2bbd8ab
commit a4b1f7c983
15 changed files with 505 additions and 29 deletions

View File

@ -0,0 +1,206 @@
'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,
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 { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
import { TriggerMultiSelectCombobox } from '~/components/(dashboard)/settings/webhooks/trigger-multiselect-combobox';
import { useCurrentTeam } from '~/providers/team';
const ZEditWebhookFormSchema = ZEditWebhookMutationSchema.omit({ id: true });
type TEditWebhookFormSchema = z.infer<typeof ZEditWebhookFormSchema>;
export type WebhookPageOptions = {
params: {
id: string;
};
};
export default function WebhookPage({ params }: WebhookPageOptions) {
const { toast } = useToast();
const router = useRouter();
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: '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 max-w-xl 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>
The URL for Documenso to send webhook events to.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="enabled"
render={({ field }) => (
<FormItem>
<FormLabel>Enabled</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>Triggers</FormLabel>
<FormControl>
<TriggerMultiSelectCombobox
listValues={value}
onChange={(values: string[]) => {
onChange(values);
}}
/>
</FormControl>
<FormDescription>
The events that will trigger a webhook to be sent to your URL.
</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>
A secret that will be sent to your URL so you can verify that the request has
been sent by Documenso.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="mt-4">
<Button type="submit" loading={form.formState.isSubmitting}>
Update webhook
</Button>
</div>
</fieldset>
</form>
</Form>
</div>
);
}

View File

@ -0,0 +1,101 @@
'use client';
import Link from 'next/link';
import { Loader } from 'lucide-react';
import { DateTime } from 'luxon';
import { toFriendlyWebhookEventName } from '@documenso/lib/universal/webhook/to-friendly-webhook-event-name';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Badge } from '@documenso/ui/primitives/badge';
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';
import { LocaleDate } from '~/components/formatter/locale-date';
import { useCurrentTeam } from '~/providers/team';
export default function WebhookPage() {
const team = useCurrentTeam();
const { data: webhooks, isLoading } = trpc.webhook.getTeamWebhooks.useQuery({
teamId: team.id,
});
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={cn(
'border-border rounded-lg border p-4',
!webhook.enabled && 'bg-muted/40',
)}
>
<div className="flex items-center justify-between gap-x-4">
<div>
<div className="truncate font-mono text-xs">{webhook.id}</div>
<div className="mt-1.5 flex items-center gap-2">
<h5 className="text-sm">{webhook.webhookUrl}</h5>
<Badge variant={webhook.enabled ? 'neutral' : 'warning'} size="small">
{webhook.enabled ? 'Enabled' : 'Disabled'}
</Badge>
</div>
<p className="text-muted-foreground mt-2 text-xs">
Listening to{' '}
{webhook.eventTriggers
.map((trigger) => toFriendlyWebhookEventName(trigger))
.join(', ')}
</p>
<p className="text-muted-foreground mt-2 text-xs">
Created on{' '}
<LocaleDate date={webhook.createdAt} format={DateTime.DATETIME_FULL} />
</p>
</div>
<div className="flex flex-shrink-0 gap-4">
<Button asChild variant="outline">
<Link href={`/t/${team.url}/settings/webhooks/${webhook.id}`}>Edit</Link>
</Button>
<DeleteWebhookDialog webhook={webhook}>
<Button variant="destructive">Delete</Button>
</DeleteWebhookDialog>
</div>
</div>
</div>
))}
</div>
)}
</div>
);
}

View File

@ -10,7 +10,7 @@ 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 { ZCreateWebhookMutationSchema } from '@documenso/trpc/server/webhook-router/schema';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
@ -35,8 +35,12 @@ import { PasswordInput } from '@documenso/ui/primitives/password-input';
import { Switch } from '@documenso/ui/primitives/switch';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useOptionalCurrentTeam } from '~/providers/team';
import { TriggerMultiSelectCombobox } from './trigger-multiselect-combobox';
const ZCreateWebhookFormSchema = ZCreateWebhookMutationSchema.omit({ teamId: true });
type TCreateWebhookFormSchema = z.infer<typeof ZCreateWebhookFormSchema>;
export type CreateWebhookDialogProps = {
@ -46,6 +50,9 @@ export type CreateWebhookDialogProps = {
export const CreateWebhookDialog = ({ trigger, ...props }: CreateWebhookDialogProps) => {
const router = useRouter();
const { toast } = useToast();
const team = useOptionalCurrentTeam();
const [open, setOpen] = useState(false);
const form = useForm<TCreateWebhookFormSchema>({
@ -60,9 +67,20 @@ export const CreateWebhookDialog = ({ trigger, ...props }: CreateWebhookDialogPr
const { mutateAsync: createWebhook } = trpc.webhook.createWebhook.useMutation();
const onSubmit = async (values: TCreateWebhookFormSchema) => {
const onSubmit = async ({
enabled,
eventTriggers,
secret,
webhookUrl,
}: TCreateWebhookFormSchema) => {
try {
await createWebhook(values);
await createWebhook({
enabled,
eventTriggers,
secret,
webhookUrl,
teamId: team?.id,
});
setOpen(false);

View File

@ -31,6 +31,8 @@ import {
import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useOptionalCurrentTeam } from '~/providers/team';
export type DeleteWebhookDialogProps = {
webhook: Pick<Webhook, 'id' | 'webhookUrl'>;
onDelete?: () => void;
@ -40,6 +42,9 @@ export type DeleteWebhookDialogProps = {
export const DeleteWebhookDialog = ({ webhook, children }: DeleteWebhookDialogProps) => {
const router = useRouter();
const { toast } = useToast();
const team = useOptionalCurrentTeam();
const [open, setOpen] = useState(false);
const deleteMessage = `delete ${webhook.webhookUrl}`;
@ -63,7 +68,7 @@ export const DeleteWebhookDialog = ({ webhook, children }: DeleteWebhookDialogPr
const onSubmit = async () => {
try {
await deleteWebhook({ id: webhook.id });
await deleteWebhook({ id: webhook.id, teamId: team?.id });
toast({
title: 'Webhook deleted',

View File

@ -5,7 +5,7 @@ import type { HTMLAttributes } from 'react';
import Link from 'next/link';
import { useParams, usePathname } from 'next/navigation';
import { Braces, CreditCard, Settings, Users } from 'lucide-react';
import { Braces, CreditCard, Settings, Users, Webhook } from 'lucide-react';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { cn } from '@documenso/ui/lib/utils';
@ -22,6 +22,7 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
const settingsPath = `/t/${teamUrl}/settings`;
const membersPath = `/t/${teamUrl}/settings/members`;
const tokensPath = `/t/${teamUrl}/settings/tokens`;
const webhooksPath = `/t/${teamUrl}/settings/webhooks`;
const billingPath = `/t/${teamUrl}/settings/billing`;
return (
@ -59,6 +60,19 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
</Button>
</Link>
<Link href={webhooksPath}>
<Button
variant="ghost"
className={cn(
'w-full justify-start',
pathname?.startsWith(webhooksPath) && 'bg-secondary',
)}
>
<Webhook className="mr-2 h-5 w-5" />
Webhooks
</Button>
</Link>
{IS_BILLING_ENABLED() && (
<Link href={billingPath}>
<Button

View File

@ -5,7 +5,7 @@ import type { HTMLAttributes } from 'react';
import Link from 'next/link';
import { useParams, usePathname } from 'next/navigation';
import { Braces, CreditCard, Key, User } from 'lucide-react';
import { Braces, CreditCard, Key, User, Webhook } from 'lucide-react';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { cn } from '@documenso/ui/lib/utils';
@ -22,6 +22,7 @@ export const MobileNav = ({ className, ...props }: MobileNavProps) => {
const settingsPath = `/t/${teamUrl}/settings`;
const membersPath = `/t/${teamUrl}/settings/members`;
const tokensPath = `/t/${teamUrl}/settings/tokens`;
const webhooksPath = `/t/${teamUrl}/settings/webhooks`;
const billingPath = `/t/${teamUrl}/settings/billing`;
return (
@ -67,6 +68,19 @@ export const MobileNav = ({ className, ...props }: MobileNavProps) => {
</Button>
</Link>
<Link href={webhooksPath}>
<Button
variant="ghost"
className={cn(
'w-full justify-start',
pathname?.startsWith(webhooksPath) && 'bg-secondary',
)}
>
<Webhook className="mr-2 h-5 w-5" />
Webhooks
</Button>
</Link>
{IS_BILLING_ENABLED() && (
<Link href={billingPath}>
<Button

View File

@ -12,7 +12,7 @@ interface TeamProviderProps {
const TeamContext = createContext<Team | null>(null);
export const useCurrentTeam = (): Team | null => {
export const useCurrentTeam = () => {
const context = useContext(TeamContext);
if (!context) {
@ -22,7 +22,7 @@ export const useCurrentTeam = (): Team | null => {
return context;
};
export const useOptionalCurrentTeam = (): Team | null => {
export const useOptionalCurrentTeam = () => {
return useContext(TeamContext);
};

View File

@ -7,6 +7,7 @@ export interface CreateWebhookOptions {
secret: string | null;
enabled: boolean;
userId: number;
teamId?: number;
}
export const createWebhook = async ({
@ -15,7 +16,21 @@ export const createWebhook = async ({
secret,
enabled,
userId,
teamId,
}: CreateWebhookOptions) => {
if (teamId) {
await prisma.team.findFirstOrThrow({
where: {
id: teamId,
members: {
some: {
userId,
},
},
},
});
}
return await prisma.webhook.create({
data: {
webhookUrl,
@ -23,6 +38,7 @@ export const createWebhook = async ({
secret,
enabled,
userId,
teamId,
},
});
};

View File

@ -3,13 +3,28 @@ import { prisma } from '@documenso/prisma';
export type DeleteWebhookByIdOptions = {
id: string;
userId: number;
teamId?: number;
};
export const deleteWebhookById = async ({ id, userId }: DeleteWebhookByIdOptions) => {
export const deleteWebhookById = async ({ id, userId, teamId }: DeleteWebhookByIdOptions) => {
return await prisma.webhook.delete({
where: {
id,
userId,
...(teamId
? {
team: {
id: teamId,
members: {
some: {
userId,
},
},
},
}
: {
userId,
teamId: null,
}),
},
});
};

View File

@ -4,15 +4,30 @@ import { prisma } from '@documenso/prisma';
export type EditWebhookOptions = {
id: string;
data: Prisma.WebhookUpdateInput;
data: Omit<Prisma.WebhookUpdateInput, 'id' | 'userId' | 'teamId'>;
userId: number;
teamId?: number;
};
export const editWebhook = async ({ id, data, userId }: EditWebhookOptions) => {
export const editWebhook = async ({ id, data, userId, teamId }: EditWebhookOptions) => {
return await prisma.webhook.update({
where: {
id,
userId,
...(teamId
? {
team: {
id: teamId,
members: {
some: {
userId,
},
},
},
}
: {
userId,
teamId: null,
}),
},
data: {
...data,

View File

@ -14,6 +14,10 @@ export const getAllWebhooksByEventTrigger = async ({
}: GetAllWebhooksByEventTriggerOptions) => {
return prisma.webhook.findMany({
where: {
enabled: true,
eventTriggers: {
has: event,
},
...(teamId
? {
team: {
@ -29,10 +33,6 @@ export const getAllWebhooksByEventTrigger = async ({
userId,
teamId: null,
}),
eventTriggers: {
has: event,
},
enabled: true,
},
});
};

View File

@ -3,13 +3,28 @@ import { prisma } from '@documenso/prisma';
export type GetWebhookByIdOptions = {
id: string;
userId: number;
teamId?: number;
};
export const getWebhookById = async ({ id, userId }: GetWebhookByIdOptions) => {
export const getWebhookById = async ({ id, userId, teamId }: GetWebhookByIdOptions) => {
return await prisma.webhook.findFirstOrThrow({
where: {
id,
userId,
...(teamId
? {
team: {
id: teamId,
members: {
some: {
userId,
},
},
},
}
: {
userId,
teamId: null,
}),
},
});
};

View File

@ -0,0 +1,19 @@
import { prisma } from '@documenso/prisma';
export const getWebhooksByTeamId = async (teamId: number, userId: number) => {
return await prisma.webhook.findMany({
where: {
team: {
id: teamId,
members: {
some: {
userId,
},
},
},
},
orderBy: {
createdAt: 'desc',
},
});
};

View File

@ -4,13 +4,15 @@ import { createWebhook } from '@documenso/lib/server-only/webhooks/create-webhoo
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 { getWebhooksByTeamId } from '@documenso/lib/server-only/webhooks/get-webhooks-by-team-id';
import { getWebhooksByUserId } from '@documenso/lib/server-only/webhooks/get-webhooks-by-user-id';
import { authenticatedProcedure, router } from '../trpc';
import {
ZCreateWebhookFormSchema,
ZCreateWebhookMutationSchema,
ZDeleteWebhookMutationSchema,
ZEditWebhookMutationSchema,
ZGetTeamWebhooksQuerySchema,
ZGetWebhookByIdQuerySchema,
} from './schema';
@ -25,15 +27,32 @@ export const webhookRouter = router({
});
}
}),
getTeamWebhooks: authenticatedProcedure
.input(ZGetTeamWebhooksQuerySchema)
.query(async ({ ctx, input }) => {
const { teamId } = input;
try {
return await getWebhooksByTeamId(teamId, 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;
const { id, teamId } = input;
return await getWebhookById({
id,
userId: ctx.user.id,
teamId,
});
} catch (err) {
throw new TRPCError({
@ -44,11 +63,17 @@ export const webhookRouter = router({
}),
createWebhook: authenticatedProcedure
.input(ZCreateWebhookFormSchema)
.input(ZCreateWebhookMutationSchema)
.mutation(async ({ input, ctx }) => {
const { enabled, eventTriggers, secret, webhookUrl, teamId } = input;
try {
return await createWebhook({
...input,
enabled,
secret,
webhookUrl,
eventTriggers,
teamId,
userId: ctx.user.id,
});
} catch (err) {
@ -58,14 +83,16 @@ export const webhookRouter = router({
});
}
}),
deleteWebhook: authenticatedProcedure
.input(ZDeleteWebhookMutationSchema)
.mutation(async ({ input, ctx }) => {
try {
const { id } = input;
const { id, teamId } = input;
return await deleteWebhookById({
id,
teamId,
userId: ctx.user.id,
});
} catch (err) {
@ -75,16 +102,18 @@ export const webhookRouter = router({
});
}
}),
editWebhook: authenticatedProcedure
.input(ZEditWebhookMutationSchema)
.mutation(async ({ input, ctx }) => {
try {
const { id } = input;
const { id, teamId, ...data } = input;
return await editWebhook({
id,
data: input,
data,
userId: ctx.user.id,
teamId,
});
} catch (err) {
throw new TRPCError({

View File

@ -2,24 +2,32 @@ import { z } from 'zod';
import { WebhookTriggerEvents } from '@documenso/prisma/client';
export const ZCreateWebhookFormSchema = z.object({
export const ZGetTeamWebhooksQuerySchema = z.object({
teamId: z.number(),
});
export type TGetTeamWebhooksQuerySchema = z.infer<typeof ZGetTeamWebhooksQuerySchema>;
export const ZCreateWebhookMutationSchema = 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(),
teamId: z.number().optional(),
});
export type TCreateWebhookFormSchema = z.infer<typeof ZCreateWebhookFormSchema>;
export type TCreateWebhookFormSchema = z.infer<typeof ZCreateWebhookMutationSchema>;
export const ZGetWebhookByIdQuerySchema = z.object({
id: z.string(),
teamId: z.number().optional(),
});
export type TGetWebhookByIdQuerySchema = z.infer<typeof ZGetWebhookByIdQuerySchema>;
export const ZEditWebhookMutationSchema = ZCreateWebhookFormSchema.extend({
export const ZEditWebhookMutationSchema = ZCreateWebhookMutationSchema.extend({
id: z.string(),
});
@ -27,6 +35,7 @@ export type TEditWebhookMutationSchema = z.infer<typeof ZEditWebhookMutationSche
export const ZDeleteWebhookMutationSchema = z.object({
id: z.string(),
teamId: z.number().optional(),
});
export type TDeleteWebhookMutationSchema = z.infer<typeof ZDeleteWebhookMutationSchema>;