From a4b1f7c9830893eeda54c47e80a1b3567aaacff3 Mon Sep 17 00:00:00 2001 From: Mythie Date: Tue, 27 Feb 2024 16:56:32 +1100 Subject: [PATCH] feat: support team webhooks --- .../[teamUrl]/settings/webhooks/[id]/page.tsx | 206 ++++++++++++++++++ .../t/[teamUrl]/settings/webhooks/page.tsx | 101 +++++++++ .../webhooks/create-webhook-dialog.tsx | 24 +- .../webhooks/delete-webhook-dialog.tsx | 7 +- .../(teams)/settings/layout/desktop-nav.tsx | 16 +- .../(teams)/settings/layout/mobile-nav.tsx | 16 +- apps/web/src/providers/team.tsx | 4 +- .../server-only/webhooks/create-webhook.ts | 16 ++ .../webhooks/delete-webhook-by-id.ts | 19 +- .../lib/server-only/webhooks/edit-webhook.ts | 21 +- .../get-all-webhooks-by-event-trigger.ts | 8 +- .../server-only/webhooks/get-webhook-by-id.ts | 19 +- .../webhooks/get-webhooks-by-team-id.ts | 19 ++ packages/trpc/server/webhook-router/router.ts | 43 +++- packages/trpc/server/webhook-router/schema.ts | 15 +- 15 files changed, 505 insertions(+), 29 deletions(-) create mode 100644 apps/web/src/app/(teams)/t/[teamUrl]/settings/webhooks/[id]/page.tsx create mode 100644 apps/web/src/app/(teams)/t/[teamUrl]/settings/webhooks/page.tsx create mode 100644 packages/lib/server-only/webhooks/get-webhooks-by-team-id.ts diff --git a/apps/web/src/app/(teams)/t/[teamUrl]/settings/webhooks/[id]/page.tsx b/apps/web/src/app/(teams)/t/[teamUrl]/settings/webhooks/[id]/page.tsx new file mode 100644 index 000000000..cc7261bda --- /dev/null +++ b/apps/web/src/app/(teams)/t/[teamUrl]/settings/webhooks/[id]/page.tsx @@ -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; + +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({ + 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 ( +
+ + + {isLoading && ( +
+ +
+ )} + +
+ +
+
+ ( + + Webhook URL + + + + + + The URL for Documenso to send webhook events to. + + + + + )} + /> + + ( + + Enabled + +
+ + + +
+ + +
+ )} + /> +
+ + ( + + Triggers + + { + onChange(values); + }} + /> + + + + The events that will trigger a webhook to be sent to your URL. + + + + + )} + /> + + ( + + Secret + + + + + + A secret that will be sent to your URL so you can verify that the request has + been sent by Documenso. + + + + )} + /> + +
+ +
+
+
+ +
+ ); +} diff --git a/apps/web/src/app/(teams)/t/[teamUrl]/settings/webhooks/page.tsx b/apps/web/src/app/(teams)/t/[teamUrl]/settings/webhooks/page.tsx new file mode 100644 index 000000000..4e41b4de6 --- /dev/null +++ b/apps/web/src/app/(teams)/t/[teamUrl]/settings/webhooks/page.tsx @@ -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 ( +
+ + + + + {isLoading && ( +
+ +
+ )} + + {webhooks && webhooks.length === 0 && ( + // TODO: Perhaps add some illustrations here to make the page more engaging +
+

+ You have no webhooks yet. Your webhooks will be shown here once you create them. +

+
+ )} + + {webhooks && webhooks.length > 0 && ( +
+ {webhooks?.map((webhook) => ( +
+
+
+
{webhook.id}
+ +
+
{webhook.webhookUrl}
+ + + {webhook.enabled ? 'Enabled' : 'Disabled'} + +
+ +

+ Listening to{' '} + {webhook.eventTriggers + .map((trigger) => toFriendlyWebhookEventName(trigger)) + .join(', ')} +

+ +

+ Created on{' '} + +

+
+ +
+ + + + +
+
+
+ ))} +
+ )} +
+ ); +} diff --git a/apps/web/src/components/(dashboard)/settings/webhooks/create-webhook-dialog.tsx b/apps/web/src/components/(dashboard)/settings/webhooks/create-webhook-dialog.tsx index 2732971b1..727054655 100644 --- a/apps/web/src/components/(dashboard)/settings/webhooks/create-webhook-dialog.tsx +++ b/apps/web/src/components/(dashboard)/settings/webhooks/create-webhook-dialog.tsx @@ -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; 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({ @@ -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); diff --git a/apps/web/src/components/(dashboard)/settings/webhooks/delete-webhook-dialog.tsx b/apps/web/src/components/(dashboard)/settings/webhooks/delete-webhook-dialog.tsx index 8f4a4008f..e65ae78b8 100644 --- a/apps/web/src/components/(dashboard)/settings/webhooks/delete-webhook-dialog.tsx +++ b/apps/web/src/components/(dashboard)/settings/webhooks/delete-webhook-dialog.tsx @@ -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; 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', diff --git a/apps/web/src/components/(teams)/settings/layout/desktop-nav.tsx b/apps/web/src/components/(teams)/settings/layout/desktop-nav.tsx index 20fe8cb2e..6964b2cee 100644 --- a/apps/web/src/components/(teams)/settings/layout/desktop-nav.tsx +++ b/apps/web/src/components/(teams)/settings/layout/desktop-nav.tsx @@ -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) => { + + + + {IS_BILLING_ENABLED() && ( + + + + {IS_BILLING_ENABLED() && (