diff options
| author | Mohamed Bassem <me@mbassem.com> | 2025-01-19 13:55:40 +0000 |
|---|---|---|
| committer | Mohamed Bassem <me@mbassem.com> | 2025-01-19 19:06:48 +0000 |
| commit | cddaefd9420507318d71f56355ff5a6648dcd951 (patch) | |
| tree | cf196ef12c36fdb0502b5ebf0f722ab32de8e2c0 /apps | |
| parent | 64f24acb9a1835ea7f0bec241c233c3e4a202d46 (diff) | |
| download | karakeep-cddaefd9420507318d71f56355ff5a6648dcd951.tar.zst | |
feat: Change webhooks to be configurable by users
Diffstat (limited to 'apps')
| -rw-r--r-- | apps/web/app/settings/layout.tsx | 6 | ||||
| -rw-r--r-- | apps/web/app/settings/webhooks/page.tsx | 5 | ||||
| -rw-r--r-- | apps/web/components/settings/WebhookEventSelector.tsx | 70 | ||||
| -rw-r--r-- | apps/web/components/settings/WebhookSettings.tsx | 514 | ||||
| -rw-r--r-- | apps/web/components/ui/command.tsx | 154 | ||||
| -rw-r--r-- | apps/web/lib/i18n/locales/en/translation.json | 18 | ||||
| -rw-r--r-- | apps/web/package.json | 1 | ||||
| -rw-r--r-- | apps/workers/crawlerWorker.ts | 4 | ||||
| -rw-r--r-- | apps/workers/webhookWorker.ts | 21 |
9 files changed, 782 insertions, 11 deletions
diff --git a/apps/web/app/settings/layout.tsx b/apps/web/app/settings/layout.tsx index bbff68a9..909dfd9c 100644 --- a/apps/web/app/settings/layout.tsx +++ b/apps/web/app/settings/layout.tsx @@ -10,6 +10,7 @@ import { Rss, Sparkles, User, + Webhook, } from "lucide-react"; const settingsSidebarItems = ( @@ -54,6 +55,11 @@ const settingsSidebarItems = ( icon: <Link size={18} />, path: "/settings/broken-links", }, + { + name: t("settings.webhooks.webhooks"), + icon: <Webhook size={18} />, + path: "/settings/webhooks", + }, ]; export default async function SettingsLayout({ diff --git a/apps/web/app/settings/webhooks/page.tsx b/apps/web/app/settings/webhooks/page.tsx new file mode 100644 index 00000000..327d0d8f --- /dev/null +++ b/apps/web/app/settings/webhooks/page.tsx @@ -0,0 +1,5 @@ +import WebhookSettings from "@/components/settings/WebhookSettings"; + +export default function WebhookSettingsPage() { + return <WebhookSettings />; +} diff --git a/apps/web/components/settings/WebhookEventSelector.tsx b/apps/web/components/settings/WebhookEventSelector.tsx new file mode 100644 index 00000000..ef357754 --- /dev/null +++ b/apps/web/components/settings/WebhookEventSelector.tsx @@ -0,0 +1,70 @@ +import { Button } from "@/components/ui/button"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { Check, ChevronsUpDown } from "lucide-react"; + +import { + ZWebhookEvent, + zWebhookEventSchema, +} from "@hoarder/shared/types/webhooks"; + +export function WebhookEventSelector({ + value, + onChange, +}: { + value: ZWebhookEvent[]; + onChange: (value: ZWebhookEvent[]) => void; +}) { + return ( + <Popover> + <PopoverTrigger asChild> + <Button + variant="outline" + role="combobox" + className="w-full justify-between" + > + {value.length > 0 ? value.join(", ") : "Select events"} + <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> + </Button> + </PopoverTrigger> + <PopoverContent> + <Command> + <CommandInput placeholder="Search events..." /> + <CommandList> + <CommandEmpty>No events found.</CommandEmpty> + <CommandGroup> + {zWebhookEventSchema.options.map((eventType) => ( + <CommandItem + key={eventType} + value={eventType} + onSelect={() => { + const newEvents = value.includes(eventType) + ? value.filter((e) => e !== eventType) + : [...value, eventType]; + onChange(newEvents); + }} + > + {eventType} + {value?.includes(eventType) && ( + <Check className="ml-auto h-4 w-4" /> + )} + </CommandItem> + ))} + </CommandGroup> + </CommandList> + </Command> + </PopoverContent> + </Popover> + ); +} diff --git a/apps/web/components/settings/WebhookSettings.tsx b/apps/web/components/settings/WebhookSettings.tsx new file mode 100644 index 00000000..4f7f72dc --- /dev/null +++ b/apps/web/components/settings/WebhookSettings.tsx @@ -0,0 +1,514 @@ +"use client"; + +import React from "react"; +import { ActionButton } from "@/components/ui/action-button"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { FullPageSpinner } from "@/components/ui/full-page-spinner"; +import { Input } from "@/components/ui/input"; +import { toast } from "@/components/ui/use-toast"; +import { useTranslation } from "@/lib/i18n/client"; +import { api } from "@/lib/trpc"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Edit, KeyRound, Plus, Save, Trash2, X } from "lucide-react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; + +import { + zNewWebhookSchema, + zUpdateWebhookSchema, + ZWebhook, +} from "@hoarder/shared/types/webhooks"; + +import ActionConfirmingDialog from "../ui/action-confirming-dialog"; +import { Button } from "../ui/button"; +import { + Dialog, + DialogClose, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "../ui/dialog"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "../ui/table"; +import { WebhookEventSelector } from "./WebhookEventSelector"; + +export function WebhooksEditorDialog() { + const { t } = useTranslation(); + const [open, setOpen] = React.useState(false); + const apiUtils = api.useUtils(); + + const form = useForm<z.infer<typeof zNewWebhookSchema>>({ + resolver: zodResolver(zNewWebhookSchema), + defaultValues: { + url: "", + events: [], + token: "", + }, + }); + + React.useEffect(() => { + if (open) { + form.reset(); + } + }, [open]); + + const { mutateAsync: createWebhook, isPending: isCreating } = + api.webhooks.create.useMutation({ + onSuccess: () => { + toast({ + description: "Webhook has been created!", + }); + apiUtils.webhooks.list.invalidate(); + setOpen(false); + }, + }); + + return ( + <Dialog open={open} onOpenChange={setOpen}> + <DialogTrigger asChild> + <Button> + <Plus className="mr-2 size-4" /> + {t("settings.webhooks.create_webhook")} + </Button> + </DialogTrigger> + <DialogContent> + <DialogHeader> + <DialogTitle>{t("settings.webhooks.create_webhook")}</DialogTitle> + </DialogHeader> + <Form {...form}> + <form + className="flex flex-col gap-3" + onSubmit={form.handleSubmit(async (value) => { + await createWebhook(value); + form.resetField("url"); + form.resetField("events"); + })} + > + <FormField + control={form.control} + name="url" + render={({ field }) => { + return ( + <FormItem className="flex-1"> + <FormLabel>URL</FormLabel> + <FormControl> + <Input placeholder="Webhook URL" type="text" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + ); + }} + /> + <FormField + control={form.control} + name="token" + render={({ field }) => ( + <FormItem className="flex-1"> + <FormLabel>{t("settings.webhooks.auth_token")}</FormLabel> + <FormControl> + <Input + type="password" + placeholder="Authentication token" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + <FormField + control={form.control} + name="events" + render={({ field }) => ( + <FormItem className="flex-1"> + <FormLabel>Events</FormLabel> + <WebhookEventSelector + value={field.value} + onChange={field.onChange} + /> + <FormMessage /> + </FormItem> + )} + /> + </form> + </Form> + <DialogFooter> + <DialogClose asChild> + <Button type="button" variant="secondary"> + Close + </Button> + </DialogClose> + <ActionButton + onClick={form.handleSubmit(async (value) => { + await createWebhook(value); + })} + loading={isCreating} + variant="default" + className="items-center" + > + <Plus className="mr-2 size-4" /> + Add + </ActionButton> + </DialogFooter> + </DialogContent> + </Dialog> + ); +} + +export function EditWebhookDialog({ webhook }: { webhook: ZWebhook }) { + const { t } = useTranslation(); + const apiUtils = api.useUtils(); + const [open, setOpen] = React.useState(false); + React.useEffect(() => { + if (open) { + form.reset({ + webhookId: webhook.id, + url: webhook.url, + events: webhook.events, + }); + } + }, [open]); + const { mutateAsync: updateWebhook, isPending: isUpdating } = + api.webhooks.update.useMutation({ + onSuccess: () => { + toast({ + description: "Webhook has been updated!", + }); + setOpen(false); + apiUtils.webhooks.list.invalidate(); + }, + }); + const updateSchema = zUpdateWebhookSchema.required({ + events: true, + url: true, + }); + const form = useForm<z.infer<typeof updateSchema>>({ + resolver: zodResolver(updateSchema), + defaultValues: { + webhookId: webhook.id, + url: webhook.url, + events: webhook.events, + }, + }); + return ( + <Dialog open={open} onOpenChange={setOpen}> + <DialogTrigger asChild> + <Button variant="secondary"> + <Edit className="mr-2 size-4" /> + {t("actions.edit")} + </Button> + </DialogTrigger> + <DialogContent> + <DialogHeader> + <DialogTitle>{t("settings.webhooks.edit_webhook")}</DialogTitle> + </DialogHeader> + + <Form {...form}> + <form + className="flex flex-col gap-3" + onSubmit={form.handleSubmit(async (value) => { + await updateWebhook(value); + })} + > + <FormField + control={form.control} + name="webhookId" + render={({ field }) => { + return ( + <FormItem className="hidden"> + <FormControl> + <Input type="hidden" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + ); + }} + /> + <FormField + control={form.control} + name="url" + render={({ field }) => { + return ( + <FormItem className="flex-1"> + <FormLabel>{t("common.url")}</FormLabel> + <FormControl> + <Input placeholder="Webhook URL" type="text" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + ); + }} + /> + <FormField + control={form.control} + name="events" + render={({ field }) => ( + <FormItem className="flex-1"> + <FormLabel>{t("settings.webhooks.events.title")}</FormLabel> + <WebhookEventSelector + value={field.value} + onChange={field.onChange} + /> + <FormMessage /> + </FormItem> + )} + /> + </form> + </Form> + <DialogFooter> + <DialogClose asChild> + <Button type="button" variant="secondary"> + {t("actions.close")} + </Button> + </DialogClose> + <ActionButton + loading={isUpdating} + onClick={form.handleSubmit(async (value) => { + await updateWebhook(value); + })} + type="submit" + className="items-center" + > + <Save className="mr-2 size-4" /> + {t("actions.save")} + </ActionButton> + </DialogFooter> + </DialogContent> + </Dialog> + ); +} + +export function EditTokenDialog({ webhook }: { webhook: ZWebhook }) { + const { t } = useTranslation(); + const apiUtils = api.useUtils(); + const [open, setOpen] = React.useState(false); + React.useEffect(() => { + if (open) { + form.reset({ + webhookId: webhook.id, + token: "", + }); + } + }, [open]); + + const updateSchema = zUpdateWebhookSchema + .pick({ + webhookId: true, + token: true, + }) + .required({ + token: true, + }); + + const form = useForm<z.infer<typeof updateSchema>>({ + resolver: zodResolver(updateSchema), + defaultValues: { + webhookId: webhook.id, + token: "", + }, + }); + + const { mutateAsync: updateWebhook, isPending: isUpdating } = + api.webhooks.update.useMutation({ + onSuccess: () => { + toast({ + description: "Webhook token has been updated!", + }); + setOpen(false); + apiUtils.webhooks.list.invalidate(); + }, + }); + + return ( + <Dialog open={open} onOpenChange={setOpen}> + <DialogTrigger asChild> + <Button variant="secondary"> + <KeyRound className="mr-2 size-4" /> + {webhook.hasToken + ? t("settings.webhooks.edit_auth_token") + : t("settings.webhooks.add_auth_token")} + </Button> + </DialogTrigger> + <DialogContent> + <DialogHeader> + <DialogTitle>{t("settings.webhooks.edit_auth_token")}</DialogTitle> + </DialogHeader> + <Form {...form}> + <form + className="flex flex-col gap-3" + onSubmit={form.handleSubmit(async (value) => { + await updateWebhook(value); + })} + > + <FormField + control={form.control} + name="webhookId" + render={({ field }) => ( + <FormItem className="hidden"> + <FormControl> + <Input type="hidden" {...field} /> + </FormControl> + </FormItem> + )} + /> + <FormField + control={form.control} + name="token" + render={({ field }) => ( + <FormItem className="flex-1"> + <FormLabel>{t("settings.webhooks.auth_token")}</FormLabel> + <FormControl> + <Input + type="password" + placeholder="Authentication token" + {...field} + value={field.value ?? ""} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </form> + </Form> + <DialogFooter> + <DialogClose asChild> + <Button type="button" variant="secondary"> + {t("actions.close")} + </Button> + </DialogClose> + <ActionButton + variant="destructive" + loading={isUpdating} + onClick={form.handleSubmit(async (value) => { + await updateWebhook({ + webhookId: value.webhookId, + token: null, + }); + })} + className="items-center" + > + <Trash2 className="mr-2 size-4" /> + {t("actions.delete")} + </ActionButton> + <ActionButton + loading={isUpdating} + onClick={form.handleSubmit(async (value) => { + await updateWebhook(value); + })} + type="submit" + className="items-center" + > + <Save className="mr-2 size-4" /> + {t("actions.save")} + </ActionButton> + </DialogFooter> + </DialogContent> + </Dialog> + ); +} + +export function WebhookRow({ webhook }: { webhook: ZWebhook }) { + const { t } = useTranslation(); + const apiUtils = api.useUtils(); + const { mutate: deleteWebhook, isPending: isDeleting } = + api.webhooks.delete.useMutation({ + onSuccess: () => { + toast({ + description: "Webhook has been deleted!", + }); + apiUtils.webhooks.list.invalidate(); + }, + }); + + return ( + <TableRow> + <TableCell>{webhook.url}</TableCell> + <TableCell>{webhook.events.join(", ")}</TableCell> + <TableCell>{webhook.hasToken ? "*******" : <X />}</TableCell> + <TableCell className="flex items-center gap-2"> + <EditWebhookDialog webhook={webhook} /> + <EditTokenDialog webhook={webhook} /> + <ActionConfirmingDialog + title={t("settings.webhooks.delete_webhook")} + description={t("settings.webhooks.delete_webhook_confirmation")} + actionButton={() => ( + <ActionButton + loading={isDeleting} + variant="destructive" + onClick={() => deleteWebhook({ webhookId: webhook.id })} + className="items-center" + type="button" + > + <Trash2 className="mr-2 size-4" /> + {t("actions.delete")} + </ActionButton> + )} + > + <Button variant="destructive" disabled={isDeleting}> + <Trash2 className="mr-2 size-4" /> + {t("actions.delete")} + </Button> + </ActionConfirmingDialog> + </TableCell> + </TableRow> + ); +} + +export default function WebhookSettings() { + const { t } = useTranslation(); + const { data: webhooks, isLoading } = api.webhooks.list.useQuery(); + return ( + <div className="rounded-md border bg-background p-4"> + <div className="flex flex-col gap-2"> + <div className="flex items-center justify-between"> + <span className="flex items-center gap-2 text-lg font-medium"> + {t("settings.webhooks.webhooks")} + </span> + <WebhooksEditorDialog /> + </div> + <p className="text-sm italic text-muted-foreground"> + {t("settings.webhooks.description")} + </p> + {isLoading && <FullPageSpinner />} + {webhooks && webhooks.webhooks.length == 0 && ( + <p className="rounded-md bg-muted p-2 text-sm text-muted-foreground"> + You don't have any webhooks configured yet. + </p> + )} + {webhooks && webhooks.webhooks.length > 0 && ( + <Table className="table-auto"> + <TableHeader> + <TableRow> + <TableHead>{t("common.url")}</TableHead> + <TableHead>{t("settings.webhooks.events.title")}</TableHead> + <TableHead>{t("settings.webhooks.auth_token")}</TableHead> + <TableHead>{t("common.actions")}</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {webhooks.webhooks.map((webhook) => ( + <WebhookRow key={webhook.id} webhook={webhook} /> + ))} + </TableBody> + </Table> + )} + </div> + </div> + ); +} diff --git a/apps/web/components/ui/command.tsx b/apps/web/components/ui/command.tsx new file mode 100644 index 00000000..d6ed6ba1 --- /dev/null +++ b/apps/web/components/ui/command.tsx @@ -0,0 +1,154 @@ +"use client"; + +import type { DialogProps } from "@radix-ui/react-dialog"; +import * as React from "react"; +import { Dialog, DialogContent } from "@/components/ui/dialog"; +import { cn } from "@/lib/utils"; +import { Command as CommandPrimitive } from "cmdk"; +import { Search } from "lucide-react"; + +const Command = React.forwardRef< + React.ElementRef<typeof CommandPrimitive>, + React.ComponentPropsWithoutRef<typeof CommandPrimitive> +>(({ className, ...props }, ref) => ( + <CommandPrimitive + ref={ref} + className={cn( + "flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground", + className, + )} + {...props} + /> +)); +Command.displayName = CommandPrimitive.displayName; + +const CommandDialog = ({ children, ...props }: DialogProps) => { + return ( + <Dialog {...props}> + <DialogContent className="overflow-hidden p-0 shadow-lg"> + <Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5"> + {children} + </Command> + </DialogContent> + </Dialog> + ); +}; + +const CommandInput = React.forwardRef< + React.ElementRef<typeof CommandPrimitive.Input>, + React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input> +>(({ className, ...props }, ref) => ( + // https://github.com/shadcn-ui/ui/issues/3366 + // eslint-disable-next-line react/no-unknown-property + <div className="flex items-center border-b px-3" cmdk-input-wrapper=""> + <Search className="mr-2 h-4 w-4 shrink-0 opacity-50" /> + <CommandPrimitive.Input + ref={ref} + className={cn( + "flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50", + className, + )} + {...props} + /> + </div> +)); + +CommandInput.displayName = CommandPrimitive.Input.displayName; + +const CommandList = React.forwardRef< + React.ElementRef<typeof CommandPrimitive.List>, + React.ComponentPropsWithoutRef<typeof CommandPrimitive.List> +>(({ className, ...props }, ref) => ( + <CommandPrimitive.List + ref={ref} + className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)} + {...props} + /> +)); + +CommandList.displayName = CommandPrimitive.List.displayName; + +const CommandEmpty = React.forwardRef< + React.ElementRef<typeof CommandPrimitive.Empty>, + React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty> +>((props, ref) => ( + <CommandPrimitive.Empty + ref={ref} + className="py-6 text-center text-sm" + {...props} + /> +)); + +CommandEmpty.displayName = CommandPrimitive.Empty.displayName; + +const CommandGroup = React.forwardRef< + React.ElementRef<typeof CommandPrimitive.Group>, + React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group> +>(({ className, ...props }, ref) => ( + <CommandPrimitive.Group + ref={ref} + className={cn( + "overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground", + className, + )} + {...props} + /> +)); + +CommandGroup.displayName = CommandPrimitive.Group.displayName; + +const CommandSeparator = React.forwardRef< + React.ElementRef<typeof CommandPrimitive.Separator>, + React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator> +>(({ className, ...props }, ref) => ( + <CommandPrimitive.Separator + ref={ref} + className={cn("-mx-1 h-px bg-border", className)} + {...props} + /> +)); +CommandSeparator.displayName = CommandPrimitive.Separator.displayName; + +const CommandItem = React.forwardRef< + React.ElementRef<typeof CommandPrimitive.Item>, + React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item> +>(({ className, ...props }, ref) => ( + <CommandPrimitive.Item + ref={ref} + className={cn( + "relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected='true']:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", + className, + )} + {...props} + /> +)); + +CommandItem.displayName = CommandPrimitive.Item.displayName; + +const CommandShortcut = ({ + className, + ...props +}: React.HTMLAttributes<HTMLSpanElement>) => { + return ( + <span + className={cn( + "ml-auto text-xs tracking-widest text-muted-foreground", + className, + )} + {...props} + /> + ); +}; +CommandShortcut.displayName = "CommandShortcut"; + +export { + Command, + CommandDialog, + CommandInput, + CommandList, + CommandEmpty, + CommandGroup, + CommandItem, + CommandShortcut, + CommandSeparator, +}; diff --git a/apps/web/lib/i18n/locales/en/translation.json b/apps/web/lib/i18n/locales/en/translation.json index 1e9f8e4d..eec48ec4 100644 --- a/apps/web/lib/i18n/locales/en/translation.json +++ b/apps/web/lib/i18n/locales/en/translation.json @@ -109,6 +109,24 @@ "rss_subscriptions": "RSS Subscriptions", "add_a_subscription": "Add a Subscription" }, + "webhooks": { + "webhooks": "Webhooks", + "description": "You can use webhooks to trigger actions when bookmarks are created, changed or crawled.", + "events": { + "title": "Events", + "crawled": "Crawled", + "created": "Created", + "edited": "Edited" + }, + "auth_token": "Auth Token", + "add_auth_token": "Add Auth Token", + "edit_auth_token": "Edit Auth Token", + "create_webhook": "Create Webhook", + "delete_webhook": "Delete Webhook", + "delete_webhook_confirmation": "Are you sure you want to delete this webhook?", + "edit_webhook": "Edit Webhook", + "webhook_url": "Webhook URL" + }, "import": { "import_export": "Import / Export", "import_export_bookmarks": "Import / Export Bookmarks", diff --git a/apps/web/package.json b/apps/web/package.json index d2cbd589..f2c3eec7 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -55,6 +55,7 @@ "cheerio": "^1.0.0", "class-variance-authority": "^0.7.0", "clsx": "^2.1.0", + "cmdk": "1.0.0", "csv-parse": "^5.5.6", "dayjs": "^1.11.10", "drizzle-orm": "^0.38.3", diff --git a/apps/workers/crawlerWorker.ts b/apps/workers/crawlerWorker.ts index 9666299d..6bb4f4ac 100644 --- a/apps/workers/crawlerWorker.ts +++ b/apps/workers/crawlerWorker.ts @@ -55,7 +55,7 @@ import { OpenAIQueue, triggerSearchReindex, triggerVideoWorker, - triggerWebhookWorker, + triggerWebhook, zCrawlLinkRequestSchema, } from "@hoarder/shared/queues"; import { BookmarkTypes } from "@hoarder/shared/types/bookmarks"; @@ -772,7 +772,7 @@ async function runCrawler(job: DequeuedJob<ZCrawlLinkRequest>) { await triggerVideoWorker(bookmarkId, url); // Trigger a webhook - await triggerWebhookWorker(bookmarkId, "crawled"); + await triggerWebhook(bookmarkId, "crawled"); // Do the archival as a separate last step as it has the potential for failure await archivalLogic(); diff --git a/apps/workers/webhookWorker.ts b/apps/workers/webhookWorker.ts index 5124f8a4..dec40570 100644 --- a/apps/workers/webhookWorker.ts +++ b/apps/workers/webhookWorker.ts @@ -54,20 +54,17 @@ async function fetchBookmark(linkId: string) { link: true, text: true, asset: true, + user: { + with: { + webhooks: true, + }, + }, }, }); } async function runWebhook(job: DequeuedJob<ZWebhookRequest>) { const jobId = job.id; - const webhookUrls = serverConfig.webhook.urls; - if (!webhookUrls) { - logger.info( - `[webhook][${jobId}] No webhook urls configured. Skipping webhook job.`, - ); - return; - } - const webhookToken = serverConfig.webhook.token; const webhookTimeoutSec = serverConfig.webhook.timeoutSec; const { bookmarkId } = job.data; @@ -78,12 +75,18 @@ async function runWebhook(job: DequeuedJob<ZWebhookRequest>) { ); } + if (!bookmark.user.webhooks) { + return; + } + logger.info( `[webhook][${jobId}] Starting a webhook job for bookmark with id "${bookmark.id}"`, ); await Promise.allSettled( - webhookUrls.map(async (url) => { + bookmark.user.webhooks.map(async (webhook) => { + const url = webhook.url; + const webhookToken = webhook.token; const maxRetries = serverConfig.webhook.retryTimes; let attempt = 0; let success = false; |
