diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile index 57c31995..71cff5c8 100644 --- a/apps/api/Dockerfile +++ b/apps/api/Dockerfile @@ -3,14 +3,14 @@ ARG NODE_VERSION=20.15.1 FROM node:${NODE_VERSION}-slim AS base RUN corepack enable && apt-get update && \ -apt-get install -y --no-install-recommends \ -ca-certificates \ -openssl \ -libssl3 \ -curl \ -netcat-openbsd \ -&& apt-get clean && \ -rm -rf /var/lib/apt/lists/* + apt-get install -y --no-install-recommends \ + ca-certificates \ + openssl \ + libssl3 \ + curl \ + netcat-openbsd \ + && apt-get clean && \ + rm -rf /var/lib/apt/lists/* RUN curl -fsSL \ https://raw.githubusercontent.com/pressly/goose/master/install.sh |\ @@ -36,6 +36,7 @@ COPY packages/common/package.json packages/common/ COPY packages/sdks/sdk/package.json packages/sdks/sdk/ COPY packages/constants/package.json packages/constants/ COPY packages/validation/package.json packages/validation/ +COPY packages/integrations/package.json packages/integrations/ COPY packages/sdks/sdk/package.json packages/sdks/sdk/ # BUILD @@ -93,7 +94,7 @@ COPY --from=build /app/packages/common ./packages/common COPY --from=build /app/packages/sdks/sdk ./packages/sdks/sdk COPY --from=build /app/packages/constants ./packages/constants COPY --from=build /app/packages/validation ./packages/validation - +COPY --from=build /app/packages/integrations ./packages/integrations RUN pnpm db:codegen WORKDIR /app/apps/api diff --git a/apps/api/package.json b/apps/api/package.json index 00796534..209e74cc 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -17,6 +17,7 @@ "@openpanel/common": "workspace:*", "@openpanel/db": "workspace:*", "@openpanel/logger": "workspace:*", + "@openpanel/integrations": "workspace:^", "@openpanel/queue": "workspace:*", "@openpanel/redis": "workspace:*", "@openpanel/trpc": "workspace:*", diff --git a/apps/api/src/controllers/live.controller.ts b/apps/api/src/controllers/live.controller.ts index 9bc1645b..245da185 100644 --- a/apps/api/src/controllers/live.controller.ts +++ b/apps/api/src/controllers/live.controller.ts @@ -4,12 +4,11 @@ import superjson from 'superjson'; import type * as WebSocket from 'ws'; import { getSuperJson } from '@openpanel/common'; -import type { IServiceEvent } from '@openpanel/db'; +import type { IServiceEvent, Notification } from '@openpanel/db'; import { TABLE_NAMES, getEvents, getLiveVisitors, - getProfileById, getProfileByIdCached, transformMinimalEvent, } from '@openpanel/db'; @@ -169,3 +168,77 @@ export async function wsProjectEvents( getRedisSub().off('message', message as any); }); } + +export async function wsProjectNotifications( + connection: { + socket: WebSocket; + }, + req: FastifyRequest<{ + Params: { + projectId: string; + }; + Querystring: { + token?: string; + }; + }>, +) { + const { params, query } = req; + + if (!query.token) { + connection.socket.send('No token provided'); + connection.socket.close(); + return; + } + + const subscribeToEvent = 'notification'; + const decoded = validateClerkJwt(query.token); + const userId = decoded?.sub; + const access = await getProjectAccess({ + userId: userId!, + projectId: params.projectId, + }); + + if (!access) { + connection.socket.send('No access'); + connection.socket.close(); + return; + } + + getRedisSub().subscribe(subscribeToEvent); + + const message = async (channel: string, message: string) => { + const notification = getSuperJson(message); + if (notification?.projectId === params.projectId) { + connection.socket.send(superjson.stringify(notification)); + } + }; + + getRedisSub().on('message', message as any); + + connection.socket.on('close', () => { + getRedisSub().unsubscribe(subscribeToEvent); + getRedisSub().off('message', message as any); + }); +} + +export async function wsIntegrationsSlack( + connection: { + socket: WebSocket; + }, + req: FastifyRequest<{ + Querystring: { + organizationId?: string; + }; + }>, +) { + const subscribeToEvent = 'integrations:slack'; + getRedisSub().subscribe(subscribeToEvent); + const onMessage = (channel: string, message: string) => { + connection.socket.send(JSON.stringify('ok')); + }; + getRedisSub().on('message', onMessage); + connection.socket.on('close', () => { + getRedisSub().unsubscribe(subscribeToEvent); + getRedisSub().off('message', onMessage); + }); +} diff --git a/apps/api/src/controllers/webhook.controller.ts b/apps/api/src/controllers/webhook.controller.ts index 2eea1338..a12e4ef3 100644 --- a/apps/api/src/controllers/webhook.controller.ts +++ b/apps/api/src/controllers/webhook.controller.ts @@ -1,9 +1,15 @@ import type { WebhookEvent } from '@clerk/fastify'; +import { AccessLevel, db } from '@openpanel/db'; +import { + sendSlackNotification, + slackInstaller, +} from '@openpanel/integrations/src/slack'; +import { getRedisPub } from '@openpanel/redis'; +import { zSlackAuthResponse } from '@openpanel/validation'; import type { FastifyReply, FastifyRequest } from 'fastify'; import { pathOr } from 'ramda'; import { Webhook } from 'svix'; - -import { AccessLevel, db } from '@openpanel/db'; +import { z } from 'zod'; if (!process.env.CLERK_SIGNING_SECRET) { throw new Error('CLERK_SIGNING_SECRET is required'); @@ -152,3 +158,98 @@ export async function clerkWebhook( reply.send({ success: true }); } + +const paramsSchema = z.object({ + code: z.string(), + state: z.string(), +}); + +const metadataSchema = z.object({ + organizationId: z.string(), + integrationId: z.string(), +}); + +export async function slackWebhook( + request: FastifyRequest<{ + Querystring: WebhookEvent; + }>, + reply: FastifyReply, +) { + const parsedParams = paramsSchema.safeParse(request.query); + + if (!parsedParams.success) { + request.log.error('Invalid params', parsedParams); + return reply.status(400).send({ error: 'Invalid params' }); + } + + const veryfiedState = await slackInstaller.stateStore?.verifyStateParam( + new Date(), + parsedParams.data.state, + ); + const parsedMetadata = metadataSchema.safeParse( + JSON.parse(veryfiedState?.metadata ?? '{}'), + ); + + if (!parsedMetadata.success) { + request.log.error('Invalid metadata', parsedMetadata.error.errors); + return reply.status(400).send({ error: 'Invalid metadata' }); + } + + const slackOauthAccessUrl = [ + 'https://slack.com/api/oauth.v2.access', + `?client_id=${process.env.SLACK_CLIENT_ID}`, + `&client_secret=${process.env.SLACK_CLIENT_SECRET}`, + `&code=${parsedParams.data.code}`, + `&redirect_uri=${process.env.SLACK_OAUTH_REDIRECT_URL}`, + ].join(''); + + try { + const response = await fetch(slackOauthAccessUrl); + const json = await response.json(); + const parsedJson = zSlackAuthResponse.safeParse(json); + + if (!parsedJson.success) { + request.log.error( + { + zod: parsedJson, + json, + }, + 'Failed to parse slack auth response', + ); + return reply + .status(400) + .header('Content-Type', 'text/html') + .send('

Failed to exchange code for token

'); + } + + // Send a notification first to confirm the connection + await sendSlackNotification({ + webhookUrl: parsedJson.data.incoming_webhook.url, + message: + '๐Ÿ‘‹ Hello. You have successfully connected OpenPanel.dev to your Slack workspace.', + }); + + await db.integration.update({ + where: { + id: parsedMetadata.data.integrationId, + organizationId: parsedMetadata.data.organizationId, + }, + data: { + config: { + type: 'slack', + ...parsedJson.data, + }, + }, + }); + + getRedisPub().publish('integrations:slack', 'ok'); + + reply.send({ success: true }); + } catch (err) { + request.log.error(err); + return reply + .status(500) + .header('Content-Type', 'text/html') + .send('

Failed to exchange code for token

'); + } +} diff --git a/apps/api/src/routes/live.router.ts b/apps/api/src/routes/live.router.ts index ba71b301..4cd2766c 100644 --- a/apps/api/src/routes/live.router.ts +++ b/apps/api/src/routes/live.router.ts @@ -27,6 +27,16 @@ const liveRouter: FastifyPluginCallback = (fastify, opts, done) => { { websocket: true }, controller.wsProjectEvents, ); + fastify.get( + '/notifications/:projectId', + { websocket: true }, + controller.wsProjectNotifications, + ); + fastify.get( + '/integrations/slack', + { websocket: true }, + controller.wsIntegrationsSlack, + ); done(); }); diff --git a/apps/api/src/routes/webhook.router.ts b/apps/api/src/routes/webhook.router.ts index 2f9901d5..becb9fde 100644 --- a/apps/api/src/routes/webhook.router.ts +++ b/apps/api/src/routes/webhook.router.ts @@ -7,6 +7,11 @@ const webhookRouter: FastifyPluginCallback = (fastify, opts, done) => { url: '/clerk', handler: controller.clerkWebhook, }); + fastify.route({ + method: 'GET', + url: '/slack', + handler: controller.slackWebhook, + }); done(); }; diff --git a/apps/api/tsup.config.ts b/apps/api/tsup.config.ts index dd028707..ce3e972b 100644 --- a/apps/api/tsup.config.ts +++ b/apps/api/tsup.config.ts @@ -6,12 +6,14 @@ const options: Options = { entry: ['src/index.ts'], noExternal: [/^@openpanel\/.*$/u, /^@\/.*$/u], external: ['@hyperdx/node-opentelemetry', 'winston'], + ignoreWatch: ['../../**/{.git,node_modules}/**'], sourcemap: true, splitting: false, }; if (process.env.WATCH) { options.watch = ['src/**/*', '../../packages/**/*']; + options.onSuccess = 'node dist/index.js'; options.minify = false; } diff --git a/apps/dashboard/Dockerfile b/apps/dashboard/Dockerfile index db079e54..45f93443 100644 --- a/apps/dashboard/Dockerfile +++ b/apps/dashboard/Dockerfile @@ -36,6 +36,7 @@ COPY packages/queue/package.json packages/queue/package.json COPY packages/common/package.json packages/common/package.json COPY packages/constants/package.json packages/constants/package.json COPY packages/validation/package.json packages/validation/package.json +COPY packages/integrations/package.json packages/integrations/package.json COPY packages/sdks/sdk/package.json packages/sdks/sdk/package.json # BUILD diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index 3c37d3b0..708d8518 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -19,6 +19,7 @@ "@openpanel/constants": "workspace:^", "@openpanel/db": "workspace:^", "@openpanel/nextjs": "1.0.3", + "@openpanel/integrations": "workspace:^", "@openpanel/queue": "workspace:^", "@openpanel/sdk-info": "workspace:^", "@openpanel/validation": "workspace:^", diff --git a/apps/dashboard/public/favicon.ico b/apps/dashboard/public/favicon.ico index b5336a48..403a07ef 100644 Binary files a/apps/dashboard/public/favicon.ico and b/apps/dashboard/public/favicon.ico differ diff --git a/apps/dashboard/public/logo-2.svg b/apps/dashboard/public/logo-2.svg deleted file mode 100644 index 264890a2..00000000 --- a/apps/dashboard/public/logo-2.svg +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - - - - - - - - - diff --git a/apps/dashboard/public/logo-with-text.svg b/apps/dashboard/public/logo-with-text.svg deleted file mode 100644 index 53198a73..00000000 --- a/apps/dashboard/public/logo-with-text.svg +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - - - - - - - diff --git a/apps/dashboard/public/logo.svg b/apps/dashboard/public/logo.svg index e45d0d95..82429a1b 100644 --- a/apps/dashboard/public/logo.svg +++ b/apps/dashboard/public/logo.svg @@ -1,5 +1,6 @@ - - - - + + + + + diff --git a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/realtime/realtime-live-histogram.tsx b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/realtime/realtime-live-histogram.tsx index 17e64b2e..f86d6aa7 100644 --- a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/realtime/realtime-live-histogram.tsx +++ b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/realtime/realtime-live-histogram.tsx @@ -81,7 +81,7 @@ export function RealtimeLiveHistogram({ {staticArray.map((percent, i) => (
))} @@ -101,7 +101,7 @@ export function RealtimeLiveHistogram({
+ + + Available + + + Installed + + + {tab === 'installed' && } + {tab === 'available' && } + + ); +} diff --git a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/settings/notifications/page.tsx b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/settings/notifications/page.tsx new file mode 100644 index 00000000..1c2b306c --- /dev/null +++ b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/settings/notifications/page.tsx @@ -0,0 +1,40 @@ +import { NotificationRules } from '@/components/notifications/notification-rules'; +import { Notifications } from '@/components/notifications/notifications'; +import { PageTabs, PageTabsLink } from '@/components/page-tabs'; +import { Padding } from '@/components/ui/padding'; +import { parseAsStringEnum } from 'nuqs'; + +interface PageProps { + params: { + projectId: string; + }; + searchParams: { + tab: string; + }; +} + +export default function Page({ + params: { projectId }, + searchParams, +}: PageProps) { + const tab = parseAsStringEnum(['notifications', 'rules']) + .withDefault('notifications') + .parseServerSide(searchParams.tab); + return ( + + + + Notifications + + + Rules + + + {tab === 'notifications' && } + {tab === 'rules' && } + + ); +} diff --git a/apps/dashboard/src/app/(onboarding)/onboarding/onboarding-tracking.tsx b/apps/dashboard/src/app/(onboarding)/onboarding/onboarding-tracking.tsx index eb1ad28d..448a843f 100644 --- a/apps/dashboard/src/app/(onboarding)/onboarding/onboarding-tracking.tsx +++ b/apps/dashboard/src/app/(onboarding)/onboarding/onboarding-tracking.tsx @@ -169,7 +169,7 @@ const Tracking = ({ placeholder="Add a domain" value={field.value?.split(',') ?? []} renderTag={(tag) => - tag === '*' ? 'Allow domains' : tag + tag === '*' ? 'Allow all domains' : tag } onChange={(newValue) => { field.onChange( diff --git a/apps/dashboard/src/app/apple-touch-icon.png b/apps/dashboard/src/app/apple-touch-icon.png deleted file mode 100644 index 3ad9fdad..00000000 Binary files a/apps/dashboard/src/app/apple-touch-icon.png and /dev/null differ diff --git a/apps/dashboard/src/app/favicon.ico b/apps/dashboard/src/app/favicon.ico index dd5ab726..403a07ef 100644 Binary files a/apps/dashboard/src/app/favicon.ico and b/apps/dashboard/src/app/favicon.ico differ diff --git a/apps/dashboard/src/app/providers.tsx b/apps/dashboard/src/app/providers.tsx index 403bd3a2..22b52187 100644 --- a/apps/dashboard/src/app/providers.tsx +++ b/apps/dashboard/src/app/providers.tsx @@ -15,6 +15,7 @@ import { Provider as ReduxProvider } from 'react-redux'; import { Toaster } from 'sonner'; import superjson from 'superjson'; +import { NotificationProvider } from '@/components/notifications/notification-provider'; import { OpenPanelComponent } from '@openpanel/nextjs'; function AllProviders({ children }: { children: React.ReactNode }) { @@ -76,6 +77,7 @@ function AllProviders({ children }: { children: React.ReactNode }) { {children} + diff --git a/apps/dashboard/src/components/full-page-empty-state.tsx b/apps/dashboard/src/components/full-page-empty-state.tsx index 23935f8d..e0f70426 100644 --- a/apps/dashboard/src/components/full-page-empty-state.tsx +++ b/apps/dashboard/src/components/full-page-empty-state.tsx @@ -5,7 +5,7 @@ import type { LucideIcon } from 'lucide-react'; interface FullPageEmptyStateProps { icon?: LucideIcon; title: string; - children: React.ReactNode; + children?: React.ReactNode; className?: string; } diff --git a/apps/dashboard/src/components/integrations/active-integrations.tsx b/apps/dashboard/src/components/integrations/active-integrations.tsx new file mode 100644 index 00000000..6dd74839 --- /dev/null +++ b/apps/dashboard/src/components/integrations/active-integrations.tsx @@ -0,0 +1,118 @@ +'use client'; + +import { useAppParams } from '@/hooks/useAppParams'; +import { pushModal, showConfirm } from '@/modals'; +import { api } from '@/trpc/client'; +import { useQueryClient } from '@tanstack/react-query'; +import { getQueryKey } from '@trpc/react-query'; +import { AnimatePresence, motion } from 'framer-motion'; +import { BoxSelectIcon } from 'lucide-react'; +import { useMemo } from 'react'; +import { PingBadge } from '../ping'; +import { Button } from '../ui/button'; +import { + IntegrationCard, + IntegrationCardFooter, + IntegrationCardLogo, + IntegrationCardSkeleton, +} from './integration-card'; +import { INTEGRATIONS } from './integrations'; + +export function ActiveIntegrations() { + const { organizationId } = useAppParams(); + const query = api.integration.list.useQuery({ + organizationId, + }); + const client = useQueryClient(); + const deletion = api.integration.delete.useMutation({ + onSuccess() { + client.refetchQueries( + getQueryKey(api.integration.list, { + organizationId, + }), + ); + }, + }); + + const data = useMemo(() => { + return (query.data || []) + .map((item) => { + const integration = INTEGRATIONS.find( + (integration) => integration.type === item.config.type, + )!; + return { + ...item, + integration, + }; + }) + .filter((item) => item.integration); + }, [query.data]); + + const isLoading = query.isLoading; + + return ( +
+ {isLoading && ( + <> + + + + + )} + {!isLoading && data.length === 0 && ( + + + + } + name="No integrations yet" + description="Integrations allow you to connect your systems to OpenPanel. You can add them in the available integrations section." + /> + )} + + {data.map((item) => { + return ( + + + + Connected +
+ + +
+
+
+
+ ); + })} +
+
+ ); +} diff --git a/apps/dashboard/src/components/integrations/all-integrations.tsx b/apps/dashboard/src/components/integrations/all-integrations.tsx new file mode 100644 index 00000000..713c5432 --- /dev/null +++ b/apps/dashboard/src/components/integrations/all-integrations.tsx @@ -0,0 +1,36 @@ +'use client'; + +import { Button } from '@/components/ui/button'; +import { pushModal } from '@/modals'; +import { PlugIcon, WebhookIcon } from 'lucide-react'; +import { IntegrationCard, IntegrationCardFooter } from './integration-card'; +import { INTEGRATIONS } from './integrations'; + +export function AllIntegrations() { + return ( +
+ {INTEGRATIONS.map((integration) => ( + + + + + + ))} +
+ ); +} diff --git a/apps/dashboard/src/components/integrations/forms/discord-integration.tsx b/apps/dashboard/src/components/integrations/forms/discord-integration.tsx new file mode 100644 index 00000000..27628f04 --- /dev/null +++ b/apps/dashboard/src/components/integrations/forms/discord-integration.tsx @@ -0,0 +1,91 @@ +import { InputWithLabel } from '@/components/forms/input-with-label'; +import { Button } from '@/components/ui/button'; +import { useAppParams } from '@/hooks/useAppParams'; +import { type RouterOutputs, api } from '@/trpc/client'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { sendTestDiscordNotification } from '@openpanel/integrations/src/discord'; +import { zCreateDiscordIntegration } from '@openpanel/validation'; +import { path, mergeDeepRight } from 'ramda'; +import { useForm } from 'react-hook-form'; +import { toast } from 'sonner'; +import type { z } from 'zod'; + +type IForm = z.infer; + +export function DiscordIntegrationForm({ + defaultValues, + onSuccess, +}: { + defaultValues?: RouterOutputs['integration']['get']; + onSuccess: () => void; +}) { + const { organizationId } = useAppParams(); + const form = useForm({ + defaultValues: mergeDeepRight( + { + id: defaultValues?.id, + organizationId, + config: { + type: 'discord' as const, + url: '', + headers: {}, + }, + }, + defaultValues ?? {}, + ), + resolver: zodResolver(zCreateDiscordIntegration), + }); + const mutation = api.integration.createOrUpdate.useMutation({ + onSuccess, + onError() { + toast.error('Failed to create integration'); + }, + }); + + const handleSubmit = (values: IForm) => { + mutation.mutate(values); + }; + + const handleError = () => { + toast.error('Validation error'); + }; + + const handleTest = async () => { + const webhookUrl = form.getValues('config.url'); + if (!webhookUrl) { + return toast.error('Webhook URL is required'); + } + const res = await sendTestDiscordNotification(webhookUrl); + if (res.ok) { + toast.success('Test notification sent'); + } else { + toast.error('Failed to send test notification'); + } + }; + + return ( +
+ + +
+ + +
+ + ); +} diff --git a/apps/dashboard/src/components/integrations/forms/slack-integration.tsx b/apps/dashboard/src/components/integrations/forms/slack-integration.tsx new file mode 100644 index 00000000..a548d68f --- /dev/null +++ b/apps/dashboard/src/components/integrations/forms/slack-integration.tsx @@ -0,0 +1,85 @@ +import { InputWithLabel } from '@/components/forms/input-with-label'; +import { Button } from '@/components/ui/button'; +import { useAppParams } from '@/hooks/useAppParams'; +import useWS from '@/hooks/useWS'; +import { popModal } from '@/modals'; +import { type RouterOutputs, api } from '@/trpc/client'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { zCreateSlackIntegration } from '@openpanel/validation'; +import { useQueryClient } from '@tanstack/react-query'; +import { useRef } from 'react'; +import { useForm } from 'react-hook-form'; +import { toast } from 'sonner'; +import type { z } from 'zod'; + +type IForm = z.infer; + +export function SlackIntegrationForm({ + defaultValues, + onSuccess, +}: { + defaultValues?: RouterOutputs['integration']['get']; + onSuccess: () => void; +}) { + const popup = useRef(null); + const { organizationId } = useAppParams(); + const client = useQueryClient(); + useWS('/live/integrations/slack', (res) => { + if (popup.current) { + popup.current.close(); + } + onSuccess(); + }); + const form = useForm({ + defaultValues: { + id: defaultValues?.id, + organizationId, + name: defaultValues?.name ?? '', + }, + resolver: zodResolver(zCreateSlackIntegration), + }); + const mutation = api.integration.createOrUpdateSlack.useMutation({ + async onSuccess(res) { + const url = res.slackInstallUrl; + const width = 600; + const height = 800; + const left = window.screenX + (window.outerWidth - width) / 2; + const top = window.screenY + (window.outerHeight - height) / 2.5; + popup.current = window.open( + url, + '', + `toolbar=no, location=no, directories=no, status=no, menubar=no, scrollbars=no, resizable=no, copyhistory=no, width=${width}, height=${height}, top=${top}, left=${left}`, + ); + + // The popup might have been blocked, so we redirect the user to the URL instead + if (!popup.current) { + window.location.href = url; + } + }, + onError() { + toast.error('Failed to create integration'); + }, + }); + + const handleSubmit = (values: IForm) => { + mutation.mutate(values); + }; + + const handleError = () => { + toast.error('Validation error'); + }; + + return ( +
+ + + + ); +} diff --git a/apps/dashboard/src/components/integrations/forms/webhook-integration.tsx b/apps/dashboard/src/components/integrations/forms/webhook-integration.tsx new file mode 100644 index 00000000..6a52d6ce --- /dev/null +++ b/apps/dashboard/src/components/integrations/forms/webhook-integration.tsx @@ -0,0 +1,73 @@ +import { InputWithLabel } from '@/components/forms/input-with-label'; +import { Button } from '@/components/ui/button'; +import { useAppParams } from '@/hooks/useAppParams'; +import { popModal } from '@/modals'; +import { type RouterOutputs, api } from '@/trpc/client'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { zCreateWebhookIntegration } from '@openpanel/validation'; +import { useQueryClient } from '@tanstack/react-query'; +import { path, mergeDeepRight } from 'ramda'; +import { useForm } from 'react-hook-form'; +import { toast } from 'sonner'; +import type { z } from 'zod'; + +type IForm = z.infer; + +export function WebhookIntegrationForm({ + defaultValues, + onSuccess, +}: { + defaultValues?: RouterOutputs['integration']['get']; + onSuccess: () => void; +}) { + const { organizationId } = useAppParams(); + const form = useForm({ + defaultValues: mergeDeepRight( + { + id: defaultValues?.id, + organizationId, + config: { + type: 'webhook' as const, + url: '', + headers: {}, + }, + }, + defaultValues ?? {}, + ), + resolver: zodResolver(zCreateWebhookIntegration), + }); + const client = useQueryClient(); + const mutation = api.integration.createOrUpdate.useMutation({ + onSuccess, + onError() { + toast.error('Failed to create integration'); + }, + }); + + const handleSubmit = (values: IForm) => { + mutation.mutate(values); + }; + + const handleError = () => { + toast.error('Validation error'); + }; + + return ( +
+ + + + + ); +} diff --git a/apps/dashboard/src/components/integrations/integration-card.tsx b/apps/dashboard/src/components/integrations/integration-card.tsx new file mode 100644 index 00000000..ab80513c --- /dev/null +++ b/apps/dashboard/src/components/integrations/integration-card.tsx @@ -0,0 +1,144 @@ +import { Skeleton } from '@/components/skeleton'; +import { cn } from '@/utils/cn'; +export function IntegrationCardFooter({ + children, + className, +}: { + children: React.ReactNode; + className?: string; +}) { + return ( +
+ {children} +
+ ); +} + +export function IntegrationCardHeader({ + children, + className, +}: { + children: React.ReactNode; + className?: string; +}) { + return ( +
+ {children} +
+ ); +} + +export function IntegrationCardHeaderButtons({ + children, + className, +}: { + children: React.ReactNode; + className?: string; +}) { + return ( +
+ {children} +
+ ); +} + +export function IntegrationCardLogoImage({ + src, + backgroundColor, +}: { + src: string; + backgroundColor: string; +}) { + return ( + + Integration Logo + + ); +} + +export function IntegrationCardLogo({ + children, + className, + ...props +}: { + children: React.ReactNode; +} & React.HTMLAttributes) { + return ( +
+ {children} +
+ ); +} + +export function IntegrationCard({ + icon, + name, + description, + children, +}: { + icon: React.ReactNode; + name: string; + description: string; + children?: React.ReactNode; +}) { + return ( +
+ + {children} +
+ ); +} + +export function IntegrationCardContent({ + icon, + name, + description, +}: { + icon: React.ReactNode; + name: string; + description: string; +}) { + return ( +
+ {icon} +
+

{name}

+

{description}

+
+
+ ); +} + +export function IntegrationCardSkeleton() { + return ( +
+
+ +
+ + + +
+
+
+ ); +} diff --git a/apps/dashboard/src/components/integrations/integrations.tsx b/apps/dashboard/src/components/integrations/integrations.tsx new file mode 100644 index 00000000..6c005e57 --- /dev/null +++ b/apps/dashboard/src/components/integrations/integrations.tsx @@ -0,0 +1,49 @@ +import type { IIntegrationConfig } from '@openpanel/db'; +import { WebhookIcon } from 'lucide-react'; +import { + IntegrationCardLogo, + IntegrationCardLogoImage, +} from './integration-card'; + +export const INTEGRATIONS: { + type: IIntegrationConfig['type']; + name: string; + description: string; + icon: React.ReactNode; +}[] = [ + { + type: 'slack', + name: 'Slack', + description: + 'Connect your Slack workspace to get notified when new issues are created.', + icon: ( + + ), + }, + { + type: 'discord', + name: 'Discord', + description: + 'Connect your Discord server to get notified when new issues are created.', + icon: ( + + ), + }, + { + type: 'webhook', + name: 'Webhook', + description: + 'Create a webhook to take actions in your own systems when new events are created.', + icon: ( + + + + ), + }, +]; diff --git a/apps/dashboard/src/components/links.tsx b/apps/dashboard/src/components/links.tsx index 3965cc1b..028ee400 100644 --- a/apps/dashboard/src/components/links.tsx +++ b/apps/dashboard/src/components/links.tsx @@ -13,7 +13,13 @@ export function ProjectLink({ const { organizationSlug, projectId } = useAppParams(); if (typeof props.href === 'string') { return ( - + {children} ); diff --git a/apps/dashboard/src/components/notifications/notification-provider.tsx b/apps/dashboard/src/components/notifications/notification-provider.tsx new file mode 100644 index 00000000..1e125893 --- /dev/null +++ b/apps/dashboard/src/components/notifications/notification-provider.tsx @@ -0,0 +1,17 @@ +import { useAppParams } from '@/hooks/useAppParams'; +import useWS from '@/hooks/useWS'; +import type { Notification } from '@openpanel/db'; +import { BellIcon } from 'lucide-react'; +import { toast } from 'sonner'; + +export function NotificationProvider() { + const { projectId } = useAppParams(); + useWS(`/live/notifications/${projectId}`, (notification) => { + toast(notification.title, { + description: notification.message, + icon: , + }); + }); + + return null; +} diff --git a/apps/dashboard/src/components/notifications/notification-rules.tsx b/apps/dashboard/src/components/notifications/notification-rules.tsx new file mode 100644 index 00000000..a32884ef --- /dev/null +++ b/apps/dashboard/src/components/notifications/notification-rules.tsx @@ -0,0 +1,74 @@ +'use client'; + +import { useAppParams } from '@/hooks/useAppParams'; +import { pushModal } from '@/modals'; +import { api } from '@/trpc/client'; +import { AnimatePresence, motion } from 'framer-motion'; +import { BoxSelectIcon, PlusIcon } from 'lucide-react'; +import { useMemo } from 'react'; +import { + IntegrationCard, + IntegrationCardLogo, + IntegrationCardSkeleton, +} from '../integrations/integration-card'; +import { Button } from '../ui/button'; +import { RuleCard } from './rule-card'; + +export function NotificationRules() { + const { projectId } = useAppParams(); + const query = api.notification.rules.useQuery({ + projectId, + }); + const data = useMemo(() => { + return query.data || []; + }, [query.data]); + + const isLoading = query.isLoading; + + return ( +
+
+ +
+
+ {isLoading && ( + <> + + + + + )} + {!isLoading && data.length === 0 && ( + + + + } + name="No integrations yet" + description="Integrations allow you to connect your systems to OpenPanel. You can add them in the available integrations section." + /> + )} + + {data.map((item) => { + return ( + + + + ); + })} + +
+
+ ); +} diff --git a/apps/dashboard/src/components/notifications/notifications.tsx b/apps/dashboard/src/components/notifications/notifications.tsx new file mode 100644 index 00000000..82296022 --- /dev/null +++ b/apps/dashboard/src/components/notifications/notifications.tsx @@ -0,0 +1,14 @@ +'use client'; + +import { useAppParams } from '@/hooks/useAppParams'; +import { api } from '@/trpc/client'; +import { NotificationsTable } from './table'; + +export function Notifications() { + const { projectId } = useAppParams(); + const query = api.notification.list.useQuery({ + projectId, + }); + + return ; +} diff --git a/apps/dashboard/src/components/notifications/rule-card.tsx b/apps/dashboard/src/components/notifications/rule-card.tsx new file mode 100644 index 00000000..8a8c3d87 --- /dev/null +++ b/apps/dashboard/src/components/notifications/rule-card.tsx @@ -0,0 +1,131 @@ +import { pushModal, showConfirm } from '@/modals'; +import { type RouterOutputs, api } from '@/trpc/client'; +import type { NotificationRule } from '@openpanel/db'; +import type { IChartRange, IInterval } from '@openpanel/validation'; +import { useQueryClient } from '@tanstack/react-query'; +import { getQueryKey } from '@trpc/react-query'; +import { AsteriskIcon, FilterIcon } from 'lucide-react'; +import { Fragment } from 'react'; +import { toast } from 'sonner'; +import { ColorSquare } from '../color-square'; +import { + IntegrationCardFooter, + IntegrationCardHeader, +} from '../integrations/integration-card'; +import { PingBadge } from '../ping'; +import { Badge } from '../ui/badge'; +import { Button } from '../ui/button'; +import { Tooltiper } from '../ui/tooltip'; + +function EventBadge({ + event, +}: { event: NotificationRule['config']['events'][number] }) { + return ( + + {event.filters.map((filter) => ( +
+ {filter.name} {filter.operator} {JSON.stringify(filter.value)} +
+ ))} +
+ } + > + + {event.name} + {Boolean(event.filters.length) && ( + + )} + + + ); +} + +export function RuleCard({ + rule, +}: { rule: RouterOutputs['notification']['rules'][number] }) { + const client = useQueryClient(); + const deletion = api.notification.deleteRule.useMutation({ + onSuccess() { + toast.success('Rule deleted'); + client.refetchQueries(getQueryKey(api.notification.rules)); + }, + }); + const renderConfig = () => { + switch (rule.config.type) { + case 'events': + return ( +
+
Get notified when
+ {rule.config.events.map((event) => ( + + ))} +
occurs
+
+ ); + case 'funnel': + return ( +
+
Get notified when a session has completed this funnel
+
+ {rule.config.events.map((event, index) => ( +
+ {index + 1} + +
+ ))} +
+
+ ); + } + }; + return ( +
+ +
{rule.name}
+
+
{renderConfig()}
+ +
+ {rule.integrations.map((integration) => ( + {integration.name} + ))} +
+
+ + +
+
+
+ ); +} diff --git a/apps/dashboard/src/components/notifications/table/columns.tsx b/apps/dashboard/src/components/notifications/table/columns.tsx new file mode 100644 index 00000000..e4eae86a --- /dev/null +++ b/apps/dashboard/src/components/notifications/table/columns.tsx @@ -0,0 +1,140 @@ +import { useNumber } from '@/hooks/useNumerFormatter'; +import { formatDateTime, formatTime } from '@/utils/date'; +import type { ColumnDef } from '@tanstack/react-table'; +import { isToday } from 'date-fns'; + +import { ProjectLink } from '@/components/links'; +import { PingBadge } from '@/components/ping'; +import { SerieIcon } from '@/components/report-chart/common/serie-icon'; +import type { RouterOutputs } from '@/trpc/client'; +import type { INotificationPayload } from '@openpanel/db'; + +function getEventFromPayload(payload: INotificationPayload | null) { + if (payload?.type === 'event') { + return payload.event; + } + if (payload?.type === 'funnel') { + return payload.funnel[0] || null; + } + return null; +} + +export function useColumns() { + const columns: ColumnDef[] = [ + { + accessorKey: 'title', + header: 'Title', + cell({ row }) { + const { title, isReadAt } = row.original; + return ( +
+ {isReadAt === null && Unread} + {title} +
+ ); + }, + }, + { + accessorKey: 'message', + header: 'Message', + cell({ row }) { + const { message } = row.original; + return ( +
+ {message} +
+ ); + }, + }, + { + accessorKey: 'country', + header: 'Country', + cell({ row }) { + const { payload } = row.original; + const event = getEventFromPayload(payload); + if (!event) { + return null; + } + return ( +
+ + {event.city} +
+ ); + }, + }, + { + accessorKey: 'os', + header: 'OS', + cell({ row }) { + const { payload } = row.original; + const event = getEventFromPayload(payload); + if (!event) { + return null; + } + return ( +
+ + {event.os} +
+ ); + }, + }, + { + accessorKey: 'browser', + header: 'Browser', + cell({ row }) { + const { payload } = row.original; + const event = getEventFromPayload(payload); + if (!event) { + return null; + } + return ( +
+ + {event.browser} +
+ ); + }, + }, + { + accessorKey: 'profile', + header: 'Profile', + cell({ row }) { + const { payload } = row.original; + const event = getEventFromPayload(payload); + if (!event) { + return null; + } + return ( + + {event.profileId} + + ); + }, + }, + { + accessorKey: 'createdAt', + header: 'Created at', + cell({ row }) { + const date = row.original.createdAt; + const rule = row.original.integration?.notificationRules[0]; + return ( +
+
{isToday(date) ? formatTime(date) : formatDateTime(date)}
+ {rule && ( +
+ Rule: {rule.name} +
+ )} +
+ ); + }, + }, + ]; + + return columns; +} diff --git a/apps/dashboard/src/components/notifications/table/index.tsx b/apps/dashboard/src/components/notifications/table/index.tsx new file mode 100644 index 00000000..6881543d --- /dev/null +++ b/apps/dashboard/src/components/notifications/table/index.tsx @@ -0,0 +1,64 @@ +import { DataTable } from '@/components/data-table'; +import { FullPageEmptyState } from '@/components/full-page-empty-state'; +import { Pagination } from '@/components/pagination'; +import { Button } from '@/components/ui/button'; +import { TableSkeleton } from '@/components/ui/table'; +import type { UseQueryResult } from '@tanstack/react-query'; +import { GanttChartIcon } from 'lucide-react'; +import type { Dispatch, SetStateAction } from 'react'; + +import type { Notification } from '@openpanel/db'; + +import { useColumns } from './columns'; + +type Props = + | { + query: UseQueryResult; + } + | { + query: UseQueryResult; + cursor: number; + setCursor: Dispatch>; + }; + +export const NotificationsTable = ({ query, ...props }: Props) => { + const columns = useColumns(); + const { data, isFetching, isLoading } = query; + + if (isLoading) { + return ; + } + + if (data?.length === 0) { + return ( + +

Could not find any events

+ {'cursor' in props && props.cursor !== 0 && ( + + )} +
+ ); + } + + return ( + <> + + {'cursor' in props && ( + + )} + + ); +}; diff --git a/apps/dashboard/src/components/overview/overview-live-histogram.tsx b/apps/dashboard/src/components/overview/overview-live-histogram.tsx index d9e37f59..2f9c06ef 100644 --- a/apps/dashboard/src/components/overview/overview-live-histogram.tsx +++ b/apps/dashboard/src/components/overview/overview-live-histogram.tsx @@ -79,7 +79,7 @@ export function OverviewLiveHistogram({ {staticArray.map((percent, i) => (
))} @@ -99,7 +99,7 @@ export function OverviewLiveHistogram({
+
+
+
+ ); +} + +export function PingBadge({ + children, + className, +}: { children: React.ReactNode; className?: string }) { + return ( + + + {children} + + ); +} diff --git a/apps/dashboard/src/components/settings-toggle.tsx b/apps/dashboard/src/components/settings-toggle.tsx index 75f0e7d4..37004e01 100644 --- a/apps/dashboard/src/components/settings-toggle.tsx +++ b/apps/dashboard/src/components/settings-toggle.tsx @@ -57,6 +57,14 @@ export default function SettingsToggle({ className }: Props) { References + + + Notifications + + + + Integrations + diff --git a/apps/dashboard/src/components/skeleton.tsx b/apps/dashboard/src/components/skeleton.tsx new file mode 100644 index 00000000..77671055 --- /dev/null +++ b/apps/dashboard/src/components/skeleton.tsx @@ -0,0 +1,5 @@ +import { cn } from '@/utils/cn'; + +export function Skeleton({ className }: { className?: string }) { + return
; +} diff --git a/apps/dashboard/src/components/ui/badge.tsx b/apps/dashboard/src/components/ui/badge.tsx index 6388c05f..0c56a752 100644 --- a/apps/dashboard/src/components/ui/badge.tsx +++ b/apps/dashboard/src/components/ui/badge.tsx @@ -6,7 +6,7 @@ import type { VariantProps } from 'class-variance-authority'; import type * as React from 'react'; const badgeVariants = cva( - 'inline-flex h-[20px] items-center rounded-full border px-2 text-sm font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2', + 'inline-flex h-[20px] items-center rounded border px-2 text-sm font-mono transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2', { variants: { variant: { @@ -20,6 +20,7 @@ const badgeVariants = cva( 'border-transparent bg-emerald-500 text-emerald-100 hover:bg-emerald-500/80', outline: 'text-foreground', muted: 'bg-def-100 text-foreground', + foregroundish: 'bg-foregroundish text-foregroundish-foreground', }, }, defaultVariants: { diff --git a/apps/dashboard/src/components/ui/tooltip.tsx b/apps/dashboard/src/components/ui/tooltip.tsx index b2ede636..3a932a74 100644 --- a/apps/dashboard/src/components/ui/tooltip.tsx +++ b/apps/dashboard/src/components/ui/tooltip.tsx @@ -42,6 +42,7 @@ interface TooltiperProps { side?: 'top' | 'right' | 'bottom' | 'left'; delayDuration?: number; sideOffset?: number; + disabled?: boolean; } export function Tooltiper({ asChild, @@ -52,7 +53,9 @@ export function Tooltiper({ side, delayDuration = 0, sideOffset = 10, + disabled = false, }: TooltiperProps) { + if (disabled) return children; return ( diff --git a/apps/dashboard/src/hooks/useAppParams.ts b/apps/dashboard/src/hooks/useAppParams.ts index 616c7893..875aa309 100644 --- a/apps/dashboard/src/hooks/useAppParams.ts +++ b/apps/dashboard/src/hooks/useAppParams.ts @@ -2,6 +2,7 @@ import { useParams } from 'next/navigation'; type AppParams = { organizationSlug: string; + organizationId: string; projectId: string; }; @@ -10,6 +11,7 @@ export function useAppParams() { return { ...(params ?? {}), organizationSlug: params?.organizationSlug, + organizationId: params?.organizationSlug, projectId: params?.projectId, } as T & AppParams; } diff --git a/apps/dashboard/src/modals/AddClient.tsx b/apps/dashboard/src/modals/AddClient.tsx index 74a7bfdd..ad9deafb 100644 --- a/apps/dashboard/src/modals/AddClient.tsx +++ b/apps/dashboard/src/modals/AddClient.tsx @@ -157,7 +157,9 @@ export default function AddClient(props: Props) { error={form.formState.errors.cors?.message} placeholder="Add a domain" value={field.value?.split(',') ?? []} - renderTag={(tag) => (tag === '*' ? 'Allow domains' : tag)} + renderTag={(tag) => + tag === '*' ? 'Allow all domains' : tag + } onChange={(newValue) => { field.onChange( newValue diff --git a/apps/dashboard/src/modals/EditClient.tsx b/apps/dashboard/src/modals/EditClient.tsx index fd9a9db4..2981e2bb 100644 --- a/apps/dashboard/src/modals/EditClient.tsx +++ b/apps/dashboard/src/modals/EditClient.tsx @@ -91,7 +91,7 @@ export default function EditClient({ error={formState.errors.cors?.message} placeholder="Add a domain" value={field.value?.split(',') ?? []} - renderTag={(tag) => (tag === '*' ? 'Allow domains' : tag)} + renderTag={(tag) => (tag === '*' ? 'Allow all domains' : tag)} onChange={(newValue) => { field.onChange( newValue diff --git a/apps/dashboard/src/modals/add-integration.tsx b/apps/dashboard/src/modals/add-integration.tsx new file mode 100644 index 00000000..1981c2c6 --- /dev/null +++ b/apps/dashboard/src/modals/add-integration.tsx @@ -0,0 +1,101 @@ +'use client'; + +import { api } from '@/trpc/client'; + +import { DiscordIntegrationForm } from '@/components/integrations/forms/discord-integration'; +import { SlackIntegrationForm } from '@/components/integrations/forms/slack-integration'; +import { WebhookIntegrationForm } from '@/components/integrations/forms/webhook-integration'; +import { IntegrationCardContent } from '@/components/integrations/integration-card'; +import { INTEGRATIONS } from '@/components/integrations/integrations'; +import { SheetContent } from '@/components/ui/sheet'; +import type { IIntegrationConfig } from '@openpanel/validation'; +import { useQueryClient } from '@tanstack/react-query'; +import { getQueryKey } from '@trpc/react-query'; +import { useQueryState } from 'nuqs'; +import { toast } from 'sonner'; +import { popModal } from '.'; +import { ModalHeader } from './Modal/Container'; + +interface Props { + id?: string; + type: IIntegrationConfig['type']; +} +export default function AddIntegration(props: Props) { + const query = api.integration.get.useQuery( + { + id: props.id ?? '', + }, + { + enabled: !!props.id, + }, + ); + + const integration = INTEGRATIONS.find((i) => i.type === props.type); + + const renderCard = () => { + if (!integration) { + return null; + } + return ( +
+ +
+ ); + }; + + const [tab, setTab] = useQueryState('tab', { + shallow: false, + }); + const client = useQueryClient(); + const handleSuccess = () => { + toast.success('Integration created'); + popModal(); + client.refetchQueries([ + getQueryKey(api.integration.list), + getQueryKey(api.integration.get, { id: props.id }), + ]); + if (tab !== undefined) { + setTab('installed'); + } + }; + + const renderForm = () => { + if (props.id && query.isLoading) { + return null; + } + + switch (integration?.type) { + case 'webhook': + return ( + + ); + case 'discord': + return ( + + ); + case 'slack': + return ( + + ); + default: + return null; + } + }; + + return ( + + + {renderCard()} + {renderForm()} + + ); +} diff --git a/apps/dashboard/src/modals/add-notification-rule.tsx b/apps/dashboard/src/modals/add-notification-rule.tsx new file mode 100644 index 00000000..45354f75 --- /dev/null +++ b/apps/dashboard/src/modals/add-notification-rule.tsx @@ -0,0 +1,308 @@ +'use client'; + +import { type RouterOutputs, api } from '@/trpc/client'; + +import { SheetContent } from '@/components/ui/sheet'; +import type { NotificationRule } from '@openpanel/db'; +import { useQueryClient } from '@tanstack/react-query'; +import { getQueryKey } from '@trpc/react-query'; +import { toast } from 'sonner'; +import { popModal } from '.'; +import { ModalHeader } from './Modal/Container'; + +import { ColorSquare } from '@/components/color-square'; +import { CheckboxItem } from '@/components/forms/checkbox-item'; +import { InputWithLabel, WithLabel } from '@/components/forms/input-with-label'; +import { PureFilterItem } from '@/components/report/sidebar/filters/FilterItem'; +import { Button } from '@/components/ui/button'; +import { Combobox } from '@/components/ui/combobox'; +import { ComboboxAdvanced } from '@/components/ui/combobox-advanced'; +import { useAppParams } from '@/hooks/useAppParams'; +import { useEventNames } from '@/hooks/useEventNames'; +import { useEventProperties } from '@/hooks/useEventProperties'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { shortId } from '@openpanel/common'; +import { + IChartEvent, + type IChartRange, + type IInterval, + zCreateNotificationRule, +} from '@openpanel/validation'; +import { + FilterIcon, + PlusIcon, + SaveIcon, + SmartphoneIcon, + TrashIcon, +} from 'lucide-react'; +import { + Controller, + type SubmitHandler, + type UseFormReturn, + useFieldArray, + useForm, + useWatch, +} from 'react-hook-form'; +import type { z } from 'zod'; + +interface Props { + rule?: RouterOutputs['notification']['rules'][number]; +} + +type IForm = z.infer; + +export default function AddNotificationRule({ rule }: Props) { + const client = useQueryClient(); + const { organizationId, projectId } = useAppParams(); + const form = useForm({ + resolver: zodResolver(zCreateNotificationRule), + defaultValues: { + id: rule?.id ?? '', + name: rule?.name ?? '', + sendToApp: rule?.sendToApp ?? false, + sendToEmail: rule?.sendToEmail ?? false, + integrations: + rule?.integrations.map((integration) => integration.id) ?? [], + projectId, + config: rule?.config ?? { + type: 'events', + events: [ + { + name: '', + segment: 'event', + filters: [], + }, + ], + }, + }, + }); + const mutation = api.notification.createOrUpdateRule.useMutation({ + onSuccess() { + toast.success( + rule ? 'Notification rule updated' : 'Notification rule created', + ); + client.refetchQueries( + getQueryKey(api.notification.rules, { + projectId, + }), + ); + popModal(); + }, + }); + + const eventsArray = useFieldArray({ + control: form.control, + name: 'config.events', + }); + + const onSubmit: SubmitHandler = (data) => { + mutation.mutate(data); + }; + + const integrationsQuery = api.integration.list.useQuery({ + organizationId, + }); + const integrations = integrationsQuery.data ?? []; + + return ( + + +
+ + + + ( + + )} + /> + + +
+ {eventsArray.fields.map((field, index) => { + return ( + eventsArray.remove(index)} + /> + ); + })} + +
+
+ + ( + + ({ + label: integration.name, + value: integration.id, + }))} + /> + + )} + /> + + + +
+ ); +} + +const interval: IInterval = 'day'; +const range: IChartRange = 'lastMonth'; + +function EventField({ + form, + index, + remove, +}: { + form: UseFormReturn; + index: number; + remove: () => void; +}) { + const { projectId } = useAppParams(); + const eventNames = useEventNames({ projectId, interval, range }); + const filtersArray = useFieldArray({ + control: form.control, + name: `config.events.${index}.filters`, + }); + const eventName = useWatch({ + control: form.control, + name: `config.events.${index}.name`, + }); + const properties = useEventProperties({ projectId, interval, range }); + + return ( +
+
+ {index + 1} + ( + ({ + label: item.name, + value: item.name, + }))} + /> + )} + /> + ({ + label: item, + value: item, + }))} + onChange={(value) => { + filtersArray.append({ + id: shortId(), + name: value, + operator: 'is', + value: [], + }); + }} + > +
+ {filtersArray.fields.map((filter, index) => { + return ( +
+ { + filtersArray.remove(index); + }} + onChangeValue={(value) => { + filtersArray.update(index, { + ...filter, + value, + }); + }} + onChangeOperator={(operator) => { + filtersArray.update(index, { + ...filter, + operator, + }); + }} + /> +
+ ); + })} +
+ ); +} diff --git a/apps/dashboard/src/modals/index.tsx b/apps/dashboard/src/modals/index.tsx index 32f8abd4..7a12025c 100644 --- a/apps/dashboard/src/modals/index.tsx +++ b/apps/dashboard/src/modals/index.tsx @@ -74,6 +74,8 @@ const modals = { Testimonial: dynamic(() => import('./Testimonial'), { loading: Loading, }), + AddIntegration: dynamic(() => import('./add-integration')), + AddNotificationRule: dynamic(() => import('./add-notification-rule')), }; export const { diff --git a/apps/dashboard/src/styles/globals.css b/apps/dashboard/src/styles/globals.css index 6317da65..9f5ca539 100644 --- a/apps/dashboard/src/styles/globals.css +++ b/apps/dashboard/src/styles/globals.css @@ -13,6 +13,7 @@ --background: 0 0% 100%; /* #FFFFFF */ --foreground: 222.2 84% 4.9%; /* #0C162A */ + --foregroundish: 226.49 3.06% 22.62%; --card: 0 0% 100%; /* #FFFFFF */ --card-foreground: 222.2 84% 4.9%; /* #0C162A */ @@ -52,6 +53,7 @@ --background: 0 0% 12.02%; /* #1e1e1e */ --foreground: 0 0% 98%; /* #fafafa */ + --foregroundish: 0 0% 79.23%; --card: 0 0% 10%; /* #1a1a1a */ --card-foreground: 0 0% 98%; /* #fafafa */ @@ -108,25 +110,18 @@ @apply overflow-hidden text-ellipsis whitespace-nowrap; } - .shine { - background-repeat: no-repeat; - background-position: -120px -120px, 0 0; - background-image: linear-gradient( - 0 0, - rgba(255, 255, 255, 0.2) 0%, - rgba(255, 255, 255, 0.2) 37%, - rgba(255, 255, 255, 0.8) 45%, - rgba(255, 255, 255, 0) 50% - ); - background-size: 250% 250%, 100% 100%; - transition: background-position 0s ease; + .heading { + @apply text-3xl font-semibold; } - .shine:hover { - background-position: 0 0, 0 0; - transition-duration: 0.5s; + .title { + @apply text-lg font-semibold; } + .subtitle { + @apply text-md font-medium; + } + .card { @apply rounded-md border border-border bg-card; } diff --git a/apps/public/public/clickable-demo.png b/apps/public/public/clickable-demo.png deleted file mode 100644 index fefdb7c9..00000000 Binary files a/apps/public/public/clickable-demo.png and /dev/null differ diff --git a/apps/public/public/clickhouse.png b/apps/public/public/clickhouse.png deleted file mode 100644 index c748d0ed..00000000 Binary files a/apps/public/public/clickhouse.png and /dev/null differ diff --git a/apps/public/public/getdreams.png b/apps/public/public/getdreams.png deleted file mode 100644 index f71225bd..00000000 Binary files a/apps/public/public/getdreams.png and /dev/null differ diff --git a/apps/public/public/kiddokitchen.png b/apps/public/public/kiddokitchen.png deleted file mode 100644 index 9997bb04..00000000 Binary files a/apps/public/public/kiddokitchen.png and /dev/null differ diff --git a/apps/public/public/logo-white.png b/apps/public/public/logo-white.png deleted file mode 100644 index 27f53ef7..00000000 Binary files a/apps/public/public/logo-white.png and /dev/null differ diff --git a/apps/public/public/logo.jpg b/apps/public/public/logo.jpg new file mode 100644 index 00000000..dd0ab9e8 Binary files /dev/null and b/apps/public/public/logo.jpg differ diff --git a/apps/public/public/logo.svg b/apps/public/public/logo.svg index e45d0d95..82429a1b 100644 --- a/apps/public/public/logo.svg +++ b/apps/public/public/logo.svg @@ -1,5 +1,6 @@ - - - - + + + + + diff --git a/apps/public/src/app/favicon.ico b/apps/public/src/app/favicon.ico index dd5ab726..403a07ef 100644 Binary files a/apps/public/src/app/favicon.ico and b/apps/public/src/app/favicon.ico differ diff --git a/apps/worker/Dockerfile b/apps/worker/Dockerfile index 9623dc0a..0f6895b2 100644 --- a/apps/worker/Dockerfile +++ b/apps/worker/Dockerfile @@ -29,6 +29,7 @@ COPY packages/queue/package.json ./packages/queue/ COPY packages/logger/package.json ./packages/logger/ COPY packages/common/package.json ./packages/common/ COPY packages/constants/package.json ./packages/constants/ +COPY packages/integrations/package.json packages/integrations/ # BUILD FROM base AS build @@ -70,7 +71,7 @@ COPY --from=build /app/packages/redis ./packages/redis COPY --from=build /app/packages/logger ./packages/logger COPY --from=build /app/packages/queue ./packages/queue COPY --from=build /app/packages/common ./packages/common - +COPY --from=build /app/packages/integrations ./packages/integrations RUN pnpm db:codegen WORKDIR /app/apps/worker diff --git a/apps/worker/package.json b/apps/worker/package.json index 15585f2b..1b118e92 100644 --- a/apps/worker/package.json +++ b/apps/worker/package.json @@ -13,6 +13,7 @@ "@bull-board/express": "5.21.0", "@openpanel/common": "workspace:*", "@openpanel/db": "workspace:*", + "@openpanel/integrations": "workspace:^", "@openpanel/logger": "workspace:*", "@openpanel/queue": "workspace:*", "@openpanel/redis": "workspace:*", diff --git a/apps/worker/src/index.ts b/apps/worker/src/index.ts index efaee662..8b0dc106 100644 --- a/apps/worker/src/index.ts +++ b/apps/worker/src/index.ts @@ -7,11 +7,17 @@ import express from 'express'; import { createInitialSalts } from '@openpanel/db'; import type { CronQueueType } from '@openpanel/queue'; -import { cronQueue, eventsQueue, sessionsQueue } from '@openpanel/queue'; +import { + cronQueue, + eventsQueue, + notificationQueue, + sessionsQueue, +} from '@openpanel/queue'; import { getRedisQueue } from '@openpanel/redis'; import { cronJob } from './jobs/cron'; import { eventsJob } from './jobs/events'; +import { notificationJob } from './jobs/notification'; import { sessionsJob } from './jobs/sessions'; import { register } from './metrics'; import { logger } from './utils/logger'; @@ -34,12 +40,18 @@ async function start() { workerOptions, ); const cronWorker = new Worker(cronQueue.name, cronJob, workerOptions); + const notificationWorker = new Worker( + notificationQueue.name, + notificationJob, + workerOptions, + ); createBullBoard({ queues: [ new BullMQAdapter(eventsQueue), new BullMQAdapter(sessionsQueue), new BullMQAdapter(cronQueue), + new BullMQAdapter(notificationQueue), ], serverAdapter: serverAdapter, }); @@ -62,7 +74,12 @@ async function start() { console.log(`For the UI, open http://localhost:${PORT}/`); }); - const workers = [sessionsWorker, eventsWorker, cronWorker]; + const workers = [ + sessionsWorker, + eventsWorker, + cronWorker, + notificationWorker, + ]; workers.forEach((worker) => { worker.on('error', (error) => { logger.error('worker error', { diff --git a/apps/worker/src/jobs/events.create-session-end.ts b/apps/worker/src/jobs/events.create-session-end.ts index f61e897f..5580edad 100644 --- a/apps/worker/src/jobs/events.create-session-end.ts +++ b/apps/worker/src/jobs/events.create-session-end.ts @@ -6,6 +6,7 @@ import { getTime } from '@openpanel/common'; import { type IServiceEvent, TABLE_NAMES, + checkNotificationRulesForSessionEnd, createEvent, eventBuffer, getEvents, @@ -138,6 +139,8 @@ export async function createSessionEnd( throw new Error('No last event found'); } + await checkNotificationRulesForSessionEnd(events); + return createEvent({ ...sessionStart, properties: { diff --git a/apps/worker/src/jobs/events.incoming-event.ts b/apps/worker/src/jobs/events.incoming-event.ts index cee1e1ae..32adf6fa 100644 --- a/apps/worker/src/jobs/events.incoming-event.ts +++ b/apps/worker/src/jobs/events.incoming-event.ts @@ -7,17 +7,17 @@ import { v4 as uuid } from 'uuid'; import { logger } from '@/utils/logger'; import { getTime, isSameDomain, parsePath } from '@openpanel/common'; import type { IServiceCreateEventPayload } from '@openpanel/db'; -import { createEvent } from '@openpanel/db'; +import { checkNotificationRulesForEvent, createEvent } from '@openpanel/db'; import { getLastScreenViewFromProfileId } from '@openpanel/db/src/services/event.service'; +import type { + EventsQueuePayloadCreateSessionEnd, + EventsQueuePayloadIncomingEvent, +} from '@openpanel/queue'; import { findJobByPrefix, sessionsQueue, sessionsQueueEvents, } from '@openpanel/queue'; -import type { - EventsQueuePayloadCreateSessionEnd, - EventsQueuePayloadIncomingEvent, -} from '@openpanel/queue'; import { getRedisQueue } from '@openpanel/redis'; const GLOBAL_PROPERTIES = ['__path', '__referrer']; @@ -101,6 +101,8 @@ export async function incomingEvent(job: Job) { sdkVersion, }; + await checkNotificationRulesForEvent(payload); + return createEvent(payload); } @@ -185,6 +187,8 @@ export async function incomingEvent(job: Job) { }); } + await checkNotificationRulesForEvent(payload); + return createEvent(payload); } diff --git a/apps/worker/src/jobs/notification.ts b/apps/worker/src/jobs/notification.ts new file mode 100644 index 00000000..e6795d23 --- /dev/null +++ b/apps/worker/src/jobs/notification.ts @@ -0,0 +1,71 @@ +import type { Job } from 'bullmq'; + +import { setSuperJson } from '@openpanel/common'; +import { db } from '@openpanel/db'; +import { sendDiscordNotification } from '@openpanel/integrations/src/discord'; +import { sendSlackNotification } from '@openpanel/integrations/src/slack'; +import type { NotificationQueuePayload } from '@openpanel/queue'; +import { getRedisPub } from '@openpanel/redis'; + +export async function notificationJob(job: Job) { + switch (job.data.type) { + case 'sendNotification': { + const { notification } = job.data.payload; + + if (notification.sendToApp) { + getRedisPub().publish('notification', setSuperJson(notification)); + // empty for now + return; + } + + if (notification.sendToEmail) { + // empty for now + return; + } + + if (!notification.integrationId) { + throw new Error('No integrationId provided'); + } + + const integration = await db.integration.findUniqueOrThrow({ + where: { + id: notification.integrationId, + }, + }); + + switch (integration.config.type) { + case 'webhook': { + return fetch(integration.config.url, { + method: 'POST', + headers: { + ...(integration.config.headers ?? {}), + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + title: notification.title, + message: notification.message, + }), + }); + } + case 'discord': { + return sendDiscordNotification({ + webhookUrl: integration.config.url, + message: [ + `๐Ÿ”” **${notification.title}**`, + notification.message, + ].join('\n'), + }); + } + + case 'slack': { + return sendSlackNotification({ + webhookUrl: integration.config.incoming_webhook.url, + message: [`๐Ÿ”” *${notification.title}*`, notification.message].join( + '\n', + ), + }); + } + } + } + } +} diff --git a/apps/worker/tsup.config.ts b/apps/worker/tsup.config.ts index 34f14180..464d750a 100644 --- a/apps/worker/tsup.config.ts +++ b/apps/worker/tsup.config.ts @@ -6,6 +6,7 @@ const options: Options = { entry: ['src/index.ts'], noExternal: [/^@openpanel\/.*$/u, /^@\/.*$/u], external: ['@hyperdx/node-opentelemetry', 'winston'], + ignoreWatch: ['../../**/{.git,node_modules}/**'], sourcemap: true, splitting: false, }; diff --git a/packages/common/src/object.ts b/packages/common/src/object.ts index aa51a957..405156cd 100644 --- a/packages/common/src/object.ts +++ b/packages/common/src/object.ts @@ -47,17 +47,16 @@ export function getSafeJson(str: string): T | null { export function getSuperJson(str: string): T | null { const json = getSafeJson(str); - if ( - typeof json === 'object' && - json !== null && - 'json' in json && - 'meta' in json - ) { + if (typeof json === 'object' && json !== null && 'json' in json) { return superjson.parse(str); } return json; } +export function setSuperJson(str: Record): string { + return superjson.stringify(str); +} + type AnyObject = Record; export function deepMergeObjects(target: AnyObject, source: AnyObject): T { diff --git a/packages/common/src/string.ts b/packages/common/src/string.ts index 8bbd7a4a..aee2527d 100644 --- a/packages/common/src/string.ts +++ b/packages/common/src/string.ts @@ -1,3 +1,7 @@ export function stripTrailingSlash(url: string) { return url.replace(/\/+$/, ''); } + +export function stripLeadingAndTrailingSlashes(url: string) { + return url.replace(/^[/]+|[/]+$/g, ''); +} diff --git a/packages/db/index.ts b/packages/db/index.ts index c6be61c3..05990e53 100644 --- a/packages/db/index.ts +++ b/packages/db/index.ts @@ -15,4 +15,6 @@ export * from './src/services/user.service'; export * from './src/services/reference.service'; export * from './src/services/id.service'; export * from './src/services/retention.service'; +export * from './src/services/notification.service'; export * from './src/buffers'; +export * from './src/types'; diff --git a/packages/db/package.json b/packages/db/package.json index 5890d7c2..9e33a713 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -18,9 +18,11 @@ "@openpanel/common": "workspace:*", "@openpanel/constants": "workspace:*", "@openpanel/logger": "workspace:*", + "@openpanel/queue": "workspace:^", "@openpanel/redis": "workspace:*", "@openpanel/validation": "workspace:*", "@prisma/client": "^5.1.1", + "prisma-json-types-generator": "^3.1.1", "ramda": "^0.29.1", "sqlstring": "^2.3.3", "superjson": "^1.13.3", diff --git a/packages/db/prisma/migrations/20240925202841_notifications/migration.sql b/packages/db/prisma/migrations/20240925202841_notifications/migration.sql new file mode 100644 index 00000000..e733e8cb --- /dev/null +++ b/packages/db/prisma/migrations/20240925202841_notifications/migration.sql @@ -0,0 +1,71 @@ +-- CreateEnum +CREATE TYPE "IntegrationType" AS ENUM ('app', 'mail', 'custom'); + +-- CreateTable +CREATE TABLE "notification_settings" ( + "id" UUID NOT NULL DEFAULT gen_random_uuid(), + "projectId" TEXT NOT NULL, + "settings" JSONB NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "notification_settings_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "notifications" ( + "id" UUID NOT NULL DEFAULT gen_random_uuid(), + "projectId" TEXT NOT NULL, + "title" TEXT NOT NULL, + "message" TEXT NOT NULL, + "isReadAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "integrationId" UUID, + "integrationType" "IntegrationType" NOT NULL, + + CONSTRAINT "notifications_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "integrations" ( + "id" UUID NOT NULL DEFAULT gen_random_uuid(), + "name" TEXT NOT NULL, + "type" TEXT NOT NULL, + "config" JSONB NOT NULL, + "organizationId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "integrations_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "_IntegrationToNotificationControl" ( + "A" UUID NOT NULL, + "B" UUID NOT NULL +); + +-- CreateIndex +CREATE UNIQUE INDEX "_IntegrationToNotificationControl_AB_unique" ON "_IntegrationToNotificationControl"("A", "B"); + +-- CreateIndex +CREATE INDEX "_IntegrationToNotificationControl_B_index" ON "_IntegrationToNotificationControl"("B"); + +-- AddForeignKey +ALTER TABLE "notification_settings" ADD CONSTRAINT "notification_settings_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "projects"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "notifications" ADD CONSTRAINT "notifications_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "projects"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "notifications" ADD CONSTRAINT "notifications_integrationId_fkey" FOREIGN KEY ("integrationId") REFERENCES "integrations"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "integrations" ADD CONSTRAINT "integrations_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "organizations"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_IntegrationToNotificationControl" ADD CONSTRAINT "_IntegrationToNotificationControl_A_fkey" FOREIGN KEY ("A") REFERENCES "integrations"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_IntegrationToNotificationControl" ADD CONSTRAINT "_IntegrationToNotificationControl_B_fkey" FOREIGN KEY ("B") REFERENCES "notification_settings"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/db/prisma/migrations/20240925204316_notification_send_to_app_and_email/migration.sql b/packages/db/prisma/migrations/20240925204316_notification_send_to_app_and_email/migration.sql new file mode 100644 index 00000000..aa38f132 --- /dev/null +++ b/packages/db/prisma/migrations/20240925204316_notification_send_to_app_and_email/migration.sql @@ -0,0 +1,14 @@ +/* + Warnings: + + - You are about to drop the column `integrationType` on the `notifications` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "notification_settings" ADD COLUMN "sendToApp" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "sendToEmail" BOOLEAN NOT NULL DEFAULT false; + +-- AlterTable +ALTER TABLE "notifications" DROP COLUMN "integrationType", +ADD COLUMN "sendToApp" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "sendToEmail" BOOLEAN NOT NULL DEFAULT false; diff --git a/packages/db/prisma/migrations/20240926175415_renaming/migration.sql b/packages/db/prisma/migrations/20240926175415_renaming/migration.sql new file mode 100644 index 00000000..c9f6544d --- /dev/null +++ b/packages/db/prisma/migrations/20240926175415_renaming/migration.sql @@ -0,0 +1,14 @@ +/* + Warnings: + + - You are about to drop the column `type` on the `integrations` table. All the data in the column will be lost. + - You are about to drop the column `settings` on the `notification_settings` table. All the data in the column will be lost. + - Added the required column `config` to the `notification_settings` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE "integrations" DROP COLUMN "type"; + +-- AlterTable +ALTER TABLE "notification_settings" DROP COLUMN "settings", +ADD COLUMN "config" JSONB NOT NULL; diff --git a/packages/db/prisma/migrations/20240927190558_rename_notification_controls/migration.sql b/packages/db/prisma/migrations/20240927190558_rename_notification_controls/migration.sql new file mode 100644 index 00000000..392a3e7f --- /dev/null +++ b/packages/db/prisma/migrations/20240927190558_rename_notification_controls/migration.sql @@ -0,0 +1,55 @@ +/* + Warnings: + + - You are about to drop the `_IntegrationToNotificationControl` table. If the table is not empty, all the data it contains will be lost. + - You are about to drop the `notification_settings` table. If the table is not empty, all the data it contains will be lost. + +*/ +-- DropForeignKey +ALTER TABLE "_IntegrationToNotificationControl" DROP CONSTRAINT "_IntegrationToNotificationControl_A_fkey"; + +-- DropForeignKey +ALTER TABLE "_IntegrationToNotificationControl" DROP CONSTRAINT "_IntegrationToNotificationControl_B_fkey"; + +-- DropForeignKey +ALTER TABLE "notification_settings" DROP CONSTRAINT "notification_settings_projectId_fkey"; + +-- DropTable +DROP TABLE "_IntegrationToNotificationControl"; + +-- DropTable +DROP TABLE "notification_settings"; + +-- CreateTable +CREATE TABLE "notification_rules" ( + "id" UUID NOT NULL DEFAULT gen_random_uuid(), + "projectId" TEXT NOT NULL, + "sendToApp" BOOLEAN NOT NULL DEFAULT false, + "sendToEmail" BOOLEAN NOT NULL DEFAULT false, + "config" JSONB NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "notification_rules_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "_IntegrationToNotificationRule" ( + "A" UUID NOT NULL, + "B" UUID NOT NULL +); + +-- CreateIndex +CREATE UNIQUE INDEX "_IntegrationToNotificationRule_AB_unique" ON "_IntegrationToNotificationRule"("A", "B"); + +-- CreateIndex +CREATE INDEX "_IntegrationToNotificationRule_B_index" ON "_IntegrationToNotificationRule"("B"); + +-- AddForeignKey +ALTER TABLE "notification_rules" ADD CONSTRAINT "notification_rules_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "projects"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_IntegrationToNotificationRule" ADD CONSTRAINT "_IntegrationToNotificationRule_A_fkey" FOREIGN KEY ("A") REFERENCES "integrations"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_IntegrationToNotificationRule" ADD CONSTRAINT "_IntegrationToNotificationRule_B_fkey" FOREIGN KEY ("B") REFERENCES "notification_rules"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/db/prisma/migrations/20240927195752_add_name_to_rules/migration.sql b/packages/db/prisma/migrations/20240927195752_add_name_to_rules/migration.sql new file mode 100644 index 00000000..5812fcc6 --- /dev/null +++ b/packages/db/prisma/migrations/20240927195752_add_name_to_rules/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - Added the required column `name` to the `notification_rules` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE "notification_rules" ADD COLUMN "name" TEXT NOT NULL; diff --git a/packages/db/prisma/migrations/20241001215748_add_payload_to_notifications/migration.sql b/packages/db/prisma/migrations/20241001215748_add_payload_to_notifications/migration.sql new file mode 100644 index 00000000..8ea8501b --- /dev/null +++ b/packages/db/prisma/migrations/20241001215748_add_payload_to_notifications/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "notifications" ADD COLUMN "payload" JSONB; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 714d993c..181e6314 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -5,6 +5,10 @@ generator client { provider = "prisma-client-js" } +generator json { + provider = "prisma-json-types-generator" +} + datasource db { provider = "postgresql" url = env("DATABASE_URL") @@ -30,6 +34,7 @@ model Organization { Client Client[] Dashboard Dashboard[] ShareOverview ShareOverview[] + integrations Integration[] @@map("organizations") } @@ -87,6 +92,9 @@ model Project { references Reference[] access ProjectAccess[] + notificationRules NotificationRule[] + notifications Notification[] + createdAt DateTime @default(now()) updatedAt DateTime @default(now()) @updatedAt @@ -299,3 +307,59 @@ model Reference { @@map("references") } + +enum IntegrationType { + app + mail + custom +} + +model NotificationRule { + id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid + name String + projectId String + project Project @relation(fields: [projectId], references: [id]) + integrations Integration[] + sendToApp Boolean @default(false) + sendToEmail Boolean @default(false) + /// [IPrismaNotificationRuleConfig] + config Json + createdAt DateTime @default(now()) + updatedAt DateTime @default(now()) @updatedAt + + @@map("notification_rules") +} + +model Notification { + id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid + projectId String + project Project @relation(fields: [projectId], references: [id]) + title String + message String + isReadAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @default(now()) @updatedAt + sendToApp Boolean @default(false) + sendToEmail Boolean @default(false) + integration Integration? @relation(fields: [integrationId], references: [id]) + integrationId String? @db.Uuid + /// [IPrismaNotificationPayload] + payload Json? + + @@map("notifications") +} + +model Integration { + id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid + name String + /// [IPrismaIntegrationConfig] + config Json + organization Organization @relation(fields: [organizationId], references: [id]) + organizationId String + notificationRules NotificationRule[] + notifications Notification[] + createdAt DateTime @default(now()) + updatedAt DateTime @default(now()) @updatedAt + + @@map("integrations") +} diff --git a/packages/db/src/services/chart.service.ts b/packages/db/src/services/chart.service.ts index c2ae3c3a..6fa93a4b 100644 --- a/packages/db/src/services/chart.service.ts +++ b/packages/db/src/services/chart.service.ts @@ -1,6 +1,9 @@ import { escape } from 'sqlstring'; -import { getTimezoneFromDateString } from '@openpanel/common'; +import { + getTimezoneFromDateString, + stripLeadingAndTrailingSlashes, +} from '@openpanel/common'; import type { IChartEventFilter, IGetChartDataInput, @@ -328,7 +331,10 @@ export function getEventFiltersWhereClause(filters: IChartEventFilter[]) { } case 'regex': { where[id] = value - .map((val) => `match(${name}, ${escape(String(val).trim())})`) + .map( + (val) => + `match(${name}, ${escape(stripLeadingAndTrailingSlashes(String(val)).trim())})`, + ) .join(' OR '); break; } diff --git a/packages/db/src/services/event.service.ts b/packages/db/src/services/event.service.ts index b1aecdca..45f7effb 100644 --- a/packages/db/src/services/event.service.ts +++ b/packages/db/src/services/event.service.ts @@ -17,8 +17,8 @@ import type { EventMeta, Prisma } from '../prisma-client'; import { db } from '../prisma-client'; import { createSqlBuilder } from '../sql-builder'; import { getEventFiltersWhereClause } from './chart.service'; -import { getProfiles, upsertProfile } from './profile.service'; import type { IServiceProfile } from './profile.service'; +import { getProfiles, upsertProfile } from './profile.service'; export type IImportedEvent = Omit< IClickhouseEvent, diff --git a/packages/db/src/services/notification.service.ts b/packages/db/src/services/notification.service.ts new file mode 100644 index 00000000..82b9fadc --- /dev/null +++ b/packages/db/src/services/notification.service.ts @@ -0,0 +1,314 @@ +import { stripLeadingAndTrailingSlashes } from '@openpanel/common'; +import { notificationQueue } from '@openpanel/queue'; +import { cacheable } from '@openpanel/redis'; +import type { IChartEvent, IChartEventFilter } from '@openpanel/validation'; +import { pathOr } from 'ramda'; +import { + type Integration, + type Notification, + Prisma, + db, +} from '../prisma-client'; +import type { + IServiceCreateEventPayload, + IServiceEvent, +} from './event.service'; +import { getProjectByIdCached } from './project.service'; + +type ICreateNotification = Pick< + Notification, + 'projectId' | 'title' | 'message' | 'integrationId' | 'payload' +>; + +export type INotificationPayload = + | { + type: 'event'; + event: IServiceCreateEventPayload; + } + | { + type: 'funnel'; + funnel: IServiceEvent[]; + }; + +export const APP_NOTIFICATION_INTEGRATION_ID = 'app'; +export const EMAIL_NOTIFICATION_INTEGRATION_ID = 'email'; + +export const BASE_INTEGRATIONS: Integration[] = [ + { + id: APP_NOTIFICATION_INTEGRATION_ID, + name: 'App', + createdAt: new Date(), + updatedAt: new Date(), + config: { + type: APP_NOTIFICATION_INTEGRATION_ID, + }, + organizationId: '', + }, + { + id: EMAIL_NOTIFICATION_INTEGRATION_ID, + name: 'Email', + createdAt: new Date(), + updatedAt: new Date(), + config: { + type: EMAIL_NOTIFICATION_INTEGRATION_ID, + }, + organizationId: '', + }, +]; + +export const isBaseIntegration = (id: string) => + BASE_INTEGRATIONS.find((i) => i.id === id); + +export const getNotificationRulesByProjectId = cacheable( + function getNotificationRulesByProjectId(projectId: string) { + return db.notificationRule.findMany({ + where: { + projectId, + }, + select: { + id: true, + name: true, + sendToApp: true, + sendToEmail: true, + config: true, + integrations: { + select: { + id: true, + }, + }, + }, + }); + }, + 60 * 24, +); + +function getIntegration(integrationId: string | null) { + if (integrationId === APP_NOTIFICATION_INTEGRATION_ID) { + return { + integrationId: null, + sendToApp: true, + sendToEmail: false, + }; + } + + if (integrationId === EMAIL_NOTIFICATION_INTEGRATION_ID) { + return { + integrationId: null, + sendToApp: false, + sendToEmail: true, + }; + } + + return { + sendToApp: false, + sendToEmail: false, + integrationId, + }; +} + +export async function createNotification(notification: ICreateNotification) { + const res = await db.notification.create({ + data: { + title: notification.title, + message: notification.message, + projectId: notification.projectId, + payload: notification.payload || Prisma.DbNull, + ...getIntegration(notification.integrationId), + }, + }); + + return triggerNotification(res); +} + +export function triggerNotification(notification: Notification) { + return notificationQueue.add('sendNotification', { + type: 'sendNotification', + payload: { + notification, + }, + }); +} + +function matchEventFilters( + event: IServiceCreateEventPayload, + filters: IChartEventFilter[], +) { + return filters.every((filter) => { + const { name, value, operator } = filter; + + if (value.length === 0) return true; + + if (name === 'has_profile') { + if (value.includes('true')) { + return event.profileId !== event.deviceId; + } + return event.profileId === event.deviceId; + } + + const propertyValue = ( + name.startsWith('properties.') + ? pathOr('', name.split('.'), event) + : pathOr('', [name], event) + ).trim(); + + switch (operator) { + case 'is': + return value.includes(propertyValue); + case 'isNot': + return !value.includes(propertyValue); + case 'contains': + return value.some((val) => propertyValue.includes(String(val))); + case 'doesNotContain': + return !value.some((val) => propertyValue.includes(String(val))); + case 'startsWith': + return value.some((val) => propertyValue.startsWith(String(val))); + case 'endsWith': + return value.some((val) => propertyValue.endsWith(String(val))); + case 'regex': { + return value + .map((val) => stripLeadingAndTrailingSlashes(String(val))) + .some((val) => new RegExp(val).test(propertyValue)); + } + default: + return false; + } + }); +} + +export function matchEvent( + event: IServiceCreateEventPayload, + chartEvent: IChartEvent, +) { + if (event.name !== chartEvent.name) { + return false; + } + + if (chartEvent.filters.length > 0) { + return matchEventFilters(event, chartEvent.filters); + } + + return true; +} + +export async function checkNotificationRulesForEvent( + payload: IServiceCreateEventPayload, +) { + const project = await getProjectByIdCached(payload.projectId); + const rules = await getNotificationRulesByProjectId(payload.projectId); + await Promise.all( + rules.flatMap((rule) => { + if (rule.config.type === 'events') { + const match = rule.config.events.find((event) => { + return matchEvent(payload, event); + }); + + if (!match) { + return []; + } + + const notification = { + title: `You received a new "${payload.name}" event`, + message: project?.name ? `Project: ${project?.name}` : '', + projectId: payload.projectId, + payload: { + type: 'event', + event: payload, + }, + } as const; + + const promises = rule.integrations.map((integration) => + createNotification({ + ...notification, + integrationId: integration.id, + }), + ); + + if (rule.sendToApp) { + promises.push( + createNotification({ + ...notification, + integrationId: APP_NOTIFICATION_INTEGRATION_ID, + }), + ); + } + + if (rule.sendToEmail) { + promises.push( + createNotification({ + ...notification, + integrationId: EMAIL_NOTIFICATION_INTEGRATION_ID, + }), + ); + } + + return promises; + } + }), + ); +} + +export async function checkNotificationRulesForSessionEnd( + events: IServiceEvent[], +) { + const sortedEvents = events.sort( + (a, b) => a.createdAt.getTime() - b.createdAt.getTime(), + ); + const projectId = sortedEvents[0]?.projectId; + if (!projectId) return null; + + const [project, rules] = await Promise.all([ + getProjectByIdCached(projectId), + getNotificationRulesByProjectId(projectId), + ]); + + const funnelRules = rules.filter((rule) => rule.config.type === 'funnel'); + + const notificationPromises = funnelRules.flatMap((rule) => { + // Match funnel events + let funnelIndex = 0; + const matchedEvents = []; + for (const event of sortedEvents) { + if (matchEvent(event, rule.config.events[funnelIndex]!)) { + matchedEvents.push(event); + funnelIndex++; + if (funnelIndex === rule.config.events.length) break; + } + } + + // If funnel not completed, skip this rule + if (funnelIndex < rule.config.events.length) return []; + + // Create notification object + const notification = { + title: `Funnel "${rule.name}" completed`, + message: project?.name ? `Project: ${project?.name}` : '', + projectId, + payload: { type: 'funnel', funnel: matchedEvents } as const, + }; + + // Generate notification promises + return [ + ...rule.integrations.map((integration) => + createNotification({ ...notification, integrationId: integration.id }), + ), + ...(rule.sendToApp + ? [ + createNotification({ + ...notification, + integrationId: APP_NOTIFICATION_INTEGRATION_ID, + }), + ] + : []), + ...(rule.sendToEmail + ? [ + createNotification({ + ...notification, + integrationId: EMAIL_NOTIFICATION_INTEGRATION_ID, + }), + ] + : []), + ]; + }); + + await Promise.all(notificationPromises); +} diff --git a/packages/db/src/services/project.service.ts b/packages/db/src/services/project.service.ts index 67d4b696..de2bdc3a 100644 --- a/packages/db/src/services/project.service.ts +++ b/packages/db/src/services/project.service.ts @@ -1,5 +1,6 @@ import { auth } from '@clerk/nextjs/server'; +import { cacheable } from '@openpanel/redis'; import type { Prisma, Project } from '../prisma-client'; import { db } from '../prisma-client'; @@ -24,6 +25,8 @@ export async function getProjectById(id: string) { return res; } +export const getProjectByIdCached = cacheable(getProjectById, 60 * 60 * 24); + export async function getProjectWithClients(id: string) { const res = await db.project.findUnique({ where: { diff --git a/packages/db/src/types.ts b/packages/db/src/types.ts new file mode 100644 index 00000000..d65f7ca9 --- /dev/null +++ b/packages/db/src/types.ts @@ -0,0 +1,13 @@ +import type { + IIntegrationConfig, + INotificationRuleConfig, +} from '@openpanel/validation'; +import type { INotificationPayload } from './services/notification.service'; + +declare global { + namespace PrismaJson { + type IPrismaNotificationRuleConfig = INotificationRuleConfig; + type IPrismaIntegrationConfig = IIntegrationConfig; + type IPrismaNotificationPayload = INotificationPayload; + } +} diff --git a/packages/db/tsconfig.json b/packages/db/tsconfig.json index a291eef2..56c2e45b 100644 --- a/packages/db/tsconfig.json +++ b/packages/db/tsconfig.json @@ -7,6 +7,6 @@ }, "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json" }, - "include": ["."], + "include": [".", "./src/types.ts"], "exclude": ["node_modules"] } diff --git a/packages/integrations/index.ts b/packages/integrations/index.ts new file mode 100644 index 00000000..3190b54d --- /dev/null +++ b/packages/integrations/index.ts @@ -0,0 +1,2 @@ +// Empty, import directly from src/ +export {}; diff --git a/packages/integrations/package.json b/packages/integrations/package.json new file mode 100644 index 00000000..f7c3b5e6 --- /dev/null +++ b/packages/integrations/package.json @@ -0,0 +1,18 @@ +{ + "name": "@openpanel/integrations", + "version": "0.0.1", + "main": "index.ts", + "scripts": { + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@slack/bolt": "^3.18.0", + "@slack/oauth": "^3.0.0" + }, + "devDependencies": { + "@openpanel/tsconfig": "workspace:*", + "@openpanel/validation": "workspace:*", + "@types/node": "^18.16.0", + "typescript": "^5.2.2" + } +} diff --git a/packages/integrations/src/discord.ts b/packages/integrations/src/discord.ts new file mode 100644 index 00000000..58087f5f --- /dev/null +++ b/packages/integrations/src/discord.ts @@ -0,0 +1,34 @@ +// Cred to (@OpenStatusHQ) https://github.com/openstatusHQ/openstatus/blob/main/packages/notifications/discord/src/index.ts + +export function sendDiscordNotification({ + webhookUrl, + message, +}: { + webhookUrl: string; + message: string; +}) { + return fetch(webhookUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + content: message, + avatar_url: 'https://openpanel.dev/logo.jpg', + username: 'OpenPanel Notifications', + }), + }).catch((err) => { + return { + ok: false, + json: () => Promise.resolve({}), + }; + }); +} + +export function sendTestDiscordNotification(webhookUrl: string) { + return sendDiscordNotification({ + webhookUrl, + message: + '**๐Ÿงช Test [OpenPanel.dev]()**\nIf you can read this, your Slack webhook is functioning correctly!\n', + }); +} diff --git a/packages/integrations/src/slack.ts b/packages/integrations/src/slack.ts new file mode 100644 index 00000000..4b02f385 --- /dev/null +++ b/packages/integrations/src/slack.ts @@ -0,0 +1,50 @@ +// Cred to (@c_alares) https://github.com/christianalares/seventy-seven/blob/main/packages/integrations/src/slack/index.ts + +import { LogLevel, App as SlackApp } from '@slack/bolt'; +import { InstallProvider } from '@slack/oauth'; + +const SLACK_CLIENT_ID = process.env.SLACK_CLIENT_ID; +const SLACK_CLIENT_SECRET = process.env.SLACK_CLIENT_SECRET; +const SLACK_OAUTH_REDIRECT_URL = process.env.SLACK_OAUTH_REDIRECT_URL; +const SLACK_STATE_SECRET = process.env.SLACK_STATE_SECRET; + +export const slackInstaller = new InstallProvider({ + clientId: SLACK_CLIENT_ID!, + clientSecret: SLACK_CLIENT_SECRET!, + stateSecret: SLACK_STATE_SECRET, + logLevel: process.env.NODE_ENV === 'development' ? LogLevel.DEBUG : undefined, +}); + +export const getSlackInstallUrl = ({ + integrationId, + organizationId, +}: { integrationId: string; organizationId: string }) => { + return slackInstaller.generateInstallUrl({ + scopes: [ + 'incoming-webhook', + 'chat:write', + 'chat:write.public', + 'team:read', + ], + redirectUri: SLACK_OAUTH_REDIRECT_URL, + metadata: JSON.stringify({ integrationId, organizationId }), + }); +}; + +export function sendSlackNotification({ + webhookUrl, + message, +}: { + webhookUrl: string; + message: string; +}) { + return fetch(webhookUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + text: message, + }), + }); +} diff --git a/packages/integrations/tsconfig.json b/packages/integrations/tsconfig.json new file mode 100644 index 00000000..b1ff68c0 --- /dev/null +++ b/packages/integrations/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "@openpanel/tsconfig/base.json", + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"], + }, + "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json" + }, + "include": ["."], + "exclude": ["node_modules"] +} diff --git a/packages/queue/index.ts b/packages/queue/index.ts index 9db794d7..773320ba 100644 --- a/packages/queue/index.ts +++ b/packages/queue/index.ts @@ -1,9 +1,4 @@ -export { - eventsQueue, - cronQueue, - sessionsQueue, - sessionsQueueEvents, -} from './src/queues'; +export * from './src/queues'; export type * from './src/queues'; export { findJobByPrefix } from './src/utils'; export type { JobsOptions } from 'bullmq'; diff --git a/packages/queue/src/queues.ts b/packages/queue/src/queues.ts index 434c8bd7..9f82d13d 100644 --- a/packages/queue/src/queues.ts +++ b/packages/queue/src/queues.ts @@ -1,6 +1,6 @@ import { Queue, QueueEvents } from 'bullmq'; -import type { IServiceEvent } from '@openpanel/db'; +import type { IServiceEvent, Notification } from '@openpanel/db'; import { getRedisQueue } from '@openpanel/redis'; import type { TrackPayload } from '@openpanel/sdk'; @@ -90,3 +90,20 @@ export const cronQueue = new Queue('cron', { removeOnComplete: 10, }, }); + +export type NotificationQueuePayload = { + type: 'sendNotification'; + payload: { + notification: Notification; + }; +}; + +export const notificationQueue = new Queue( + 'notification', + { + connection: getRedisQueue(), + defaultJobOptions: { + removeOnComplete: 10, + }, + }, +); diff --git a/packages/trpc/package.json b/packages/trpc/package.json index 9fec4155..952b3671 100644 --- a/packages/trpc/package.json +++ b/packages/trpc/package.json @@ -10,8 +10,9 @@ "@openpanel/common": "workspace:*", "@openpanel/constants": "workspace:*", "@openpanel/db": "workspace:*", - "@openpanel/validation": "workspace:*", + "@openpanel/integrations": "workspace:^", "@openpanel/redis": "workspace:*", + "@openpanel/validation": "workspace:*", "@seventy-seven/sdk": "0.0.0-beta.2", "@trpc/server": "^10.45.1", "date-fns": "^3.3.1", diff --git a/packages/trpc/src/root.ts b/packages/trpc/src/root.ts index 6f6e1212..a9964385 100644 --- a/packages/trpc/src/root.ts +++ b/packages/trpc/src/root.ts @@ -2,6 +2,8 @@ import { chartRouter } from './routers/chart'; import { clientRouter } from './routers/client'; import { dashboardRouter } from './routers/dashboard'; import { eventRouter } from './routers/event'; +import { integrationRouter } from './routers/integration'; +import { notificationRouter } from './routers/notification'; import { onboardingRouter } from './routers/onboarding'; import { organizationRouter } from './routers/organization'; import { profileRouter } from './routers/profile'; @@ -32,6 +34,8 @@ export const appRouter = createTRPCRouter({ onboarding: onboardingRouter, reference: referenceRouter, ticket: ticketRouter, + notification: notificationRouter, + integration: integrationRouter, }); // export type definition of API diff --git a/packages/trpc/src/routers/integration.ts b/packages/trpc/src/routers/integration.ts new file mode 100644 index 00000000..f6270b02 --- /dev/null +++ b/packages/trpc/src/routers/integration.ts @@ -0,0 +1,137 @@ +import { z } from 'zod'; + +import { BASE_INTEGRATIONS, db } from '@openpanel/db'; + +import { getSlackInstallUrl } from '@openpanel/integrations/src/slack'; +import { + type ISlackConfig, + zCreateDiscordIntegration, + zCreateSlackIntegration, + zCreateWebhookIntegration, +} from '@openpanel/validation'; +import { getOrganizationAccessCached } from '../access'; +import { TRPCAccessError } from '../errors'; +import { createTRPCRouter, protectedProcedure } from '../trpc'; + +export const integrationRouter = createTRPCRouter({ + get: protectedProcedure + .input(z.object({ id: z.string() })) + .query(async ({ input, ctx }) => { + const integration = await db.integration.findUniqueOrThrow({ + where: { + id: input.id, + }, + }); + + const access = await getOrganizationAccessCached({ + userId: ctx.session.userId, + organizationId: integration.organizationId, + }); + + if (!access) { + throw TRPCAccessError('You do not have access to this project'); + } + + return integration; + }), + list: protectedProcedure + .input(z.object({ organizationId: z.string() })) + .query(async ({ input }) => { + const integrations = await db.integration.findMany({ + where: { + organizationId: input.organizationId, + }, + }); + + return [...BASE_INTEGRATIONS, ...integrations]; + }), + createOrUpdateSlack: protectedProcedure + .input(zCreateSlackIntegration) + .mutation(async ({ input }) => { + if (input.id) { + const res = await db.integration.update({ + where: { + id: input.id, + organizationId: input.organizationId, + }, + data: { + name: input.name, + // This is empty and will be filled by the webhook + config: {} as ISlackConfig, + }, + }); + + return { + ...res, + slackInstallUrl: await getSlackInstallUrl({ + integrationId: res.id, + organizationId: input.organizationId, + }), + }; + } + + const res = await db.integration.create({ + data: { + name: input.name, + organizationId: input.organizationId, + // This is empty and will be filled by the webhook + config: {} as ISlackConfig, + }, + }); + + return { + ...res, + slackInstallUrl: await getSlackInstallUrl({ + integrationId: res.id, + organizationId: input.organizationId, + }), + }; + }), + createOrUpdate: protectedProcedure + .input(z.union([zCreateDiscordIntegration, zCreateWebhookIntegration])) + .mutation(async ({ input }) => { + if (input.id) { + return db.integration.update({ + where: { + id: input.id, + organizationId: input.organizationId, + }, + data: { + name: input.name, + config: input.config, + }, + }); + } + return db.integration.create({ + data: { + name: input.name, + organizationId: input.organizationId, + config: input.config, + }, + }); + }), + delete: protectedProcedure + .input(z.object({ id: z.string() })) + .mutation(async ({ input: { id }, ctx }) => { + const integration = await db.integration.findUniqueOrThrow({ + where: { + id, + }, + }); + + const access = await getOrganizationAccessCached({ + userId: ctx.session.userId, + organizationId: integration.organizationId, + }); + + if (!access) { + throw TRPCAccessError('You do not have access to this project'); + } + + return db.integration.delete({ + where: { + id, + }, + }); + }), +}); diff --git a/packages/trpc/src/routers/notification.ts b/packages/trpc/src/routers/notification.ts new file mode 100644 index 00000000..38b38e20 --- /dev/null +++ b/packages/trpc/src/routers/notification.ts @@ -0,0 +1,150 @@ +import { z } from 'zod'; + +import { + APP_NOTIFICATION_INTEGRATION_ID, + BASE_INTEGRATIONS, + EMAIL_NOTIFICATION_INTEGRATION_ID, + db, + getNotificationRulesByProjectId, + isBaseIntegration, +} from '@openpanel/db'; +import { zCreateNotificationRule } from '@openpanel/validation'; + +import { getProjectAccess } from '../access'; +import { TRPCAccessError } from '../errors'; +import { createTRPCRouter, protectedProcedure } from '../trpc'; + +export const notificationRouter = createTRPCRouter({ + list: protectedProcedure + .input(z.object({ projectId: z.string() })) + .query(async ({ input }) => { + return db.notification.findMany({ + where: { + projectId: input.projectId, + sendToApp: true, + }, + orderBy: { + createdAt: 'desc', + }, + include: { + integration: { + include: { + notificationRules: { + select: { + name: true, + }, + }, + }, + }, + }, + take: 100, + }); + }), + rules: protectedProcedure + .input(z.object({ projectId: z.string() })) + .query(async ({ input }) => { + return db.notificationRule + .findMany({ + where: { + projectId: input.projectId, + }, + include: { + integrations: true, + }, + orderBy: { + createdAt: 'desc', + }, + }) + .then((rules) => { + return rules.map((rule) => { + return { + ...rule, + integrations: [ + ...BASE_INTEGRATIONS.filter((integration) => { + return ( + (integration.id === APP_NOTIFICATION_INTEGRATION_ID && + rule.sendToApp) || + (integration.id === EMAIL_NOTIFICATION_INTEGRATION_ID && + rule.sendToEmail) + ); + }), + ...rule.integrations, + ], + }; + }); + }); + }), + createOrUpdateRule: protectedProcedure + .input(zCreateNotificationRule) + .mutation(async ({ input }) => { + // Clear the cache for the project + await getNotificationRulesByProjectId.clear(input.projectId); + + if (input.id) { + return db.notificationRule.update({ + where: { + id: input.id, + }, + data: { + name: input.name, + projectId: input.projectId, + sendToApp: !!input.integrations.find( + (id) => id === APP_NOTIFICATION_INTEGRATION_ID, + ), + sendToEmail: !!input.integrations.find( + (id) => id === EMAIL_NOTIFICATION_INTEGRATION_ID, + ), + integrations: { + set: input.integrations + .filter((id) => !isBaseIntegration(id)) + .map((id) => ({ id })), + }, + config: input.config, + }, + }); + } + + return db.notificationRule.create({ + data: { + name: input.name, + projectId: input.projectId, + sendToApp: !!input.integrations.find( + (id) => id === APP_NOTIFICATION_INTEGRATION_ID, + ), + sendToEmail: !!input.integrations.find( + (id) => id === EMAIL_NOTIFICATION_INTEGRATION_ID, + ), + integrations: { + connect: input.integrations + .filter((id) => !isBaseIntegration(id)) + .map((id) => ({ id })), + }, + config: input.config, + }, + }); + }), + deleteRule: protectedProcedure + .input(z.object({ id: z.string() })) + .mutation(async ({ input: { id }, ctx }) => { + const rule = await db.notificationRule.findUniqueOrThrow({ + where: { + id, + }, + }); + + const access = await getProjectAccess({ + userId: ctx.session.userId, + projectId: rule.projectId, + }); + + if (!access) { + throw TRPCAccessError('You do not have access to this project'); + } + + return db.notificationRule.delete({ + where: { + id, + }, + }); + }), +}); diff --git a/packages/trpc/src/routers/project.ts b/packages/trpc/src/routers/project.ts index ca0b570b..8be7d864 100644 --- a/packages/trpc/src/routers/project.ts +++ b/packages/trpc/src/routers/project.ts @@ -1,6 +1,11 @@ import { z } from 'zod'; -import { db, getId, getProjectsByOrganizationSlug } from '@openpanel/db'; +import { + db, + getId, + getProjectByIdCached, + getProjectsByOrganizationSlug, +} from '@openpanel/db'; import { getProjectAccess } from '../access'; import { TRPCAccessError } from '../errors'; @@ -34,8 +39,7 @@ export const projectRouter = createTRPCRouter({ if (!access) { throw TRPCAccessError('You do not have access to this project'); } - - return db.project.update({ + const res = await db.project.update({ where: { id: input.id, }, @@ -43,6 +47,8 @@ export const projectRouter = createTRPCRouter({ name: input.name, }, }); + await getProjectByIdCached.clear(input.id); + return res; }), create: protectedProcedure .input( diff --git a/packages/validation/src/index.ts b/packages/validation/src/index.ts index 3392a47e..b6085c4b 100644 --- a/packages/validation/src/index.ts +++ b/packages/validation/src/index.ts @@ -148,3 +148,119 @@ export const zOnboardingProject = z } } }); + +export const zSlackAuthResponse = z.object({ + ok: z.literal(true), + app_id: z.string(), + authed_user: z.object({ + id: z.string(), + }), + scope: z.string(), + token_type: z.literal('bot'), + access_token: z.string(), + bot_user_id: z.string(), + team: z.object({ + id: z.string(), + name: z.string(), + }), + incoming_webhook: z.object({ + channel: z.string(), + channel_id: z.string(), + configuration_url: z.string().url(), + url: z.string().url(), + }), +}); + +export const zSlackConfig = z + .object({ + type: z.literal('slack'), + }) + .merge(zSlackAuthResponse); + +export type ISlackConfig = z.infer; + +export const zWebhookConfig = z.object({ + type: z.literal('webhook'), + url: z.string().url(), + headers: z.record(z.string()), + payload: z.record(z.string(), z.unknown()), +}); +export type IWebhookConfig = z.infer; + +export const zDiscordConfig = z.object({ + type: z.literal('discord'), + url: z.string().url(), +}); +export type IDiscordConfig = z.infer; + +export const zAppConfig = z.object({ + type: z.literal('app'), +}); +export type IAppConfig = z.infer; + +export const zEmailConfig = z.object({ + type: z.literal('email'), +}); +export type IEmailConfig = z.infer; + +export type IIntegrationConfig = + | ISlackConfig + | IDiscordConfig + | IWebhookConfig + | IAppConfig + | IEmailConfig; + +const zCreateIntegration = z.object({ + id: z.string().optional(), + name: z.string().min(1), + organizationId: z.string().min(1), +}); + +export const zCreateSlackIntegration = zCreateIntegration; + +export const zCreateWebhookIntegration = zCreateIntegration.merge( + z.object({ + config: zWebhookConfig, + }), +); + +export const zCreateDiscordIntegration = zCreateIntegration.merge( + z.object({ + config: zDiscordConfig, + }), +); + +export const zNotificationRuleEventConfig = z.object({ + type: z.literal('events'), + events: z.array(zChartEvent), +}); + +export type INotificationRuleEventConfig = z.infer< + typeof zNotificationRuleEventConfig +>; + +export const zNotificationRuleFunnelConfig = z.object({ + type: z.literal('funnel'), + events: z.array(zChartEvent).min(1), +}); + +export type INotificationRuleFunnelConfig = z.infer< + typeof zNotificationRuleFunnelConfig +>; + +export const zNotificationRuleConfig = z.discriminatedUnion('type', [ + zNotificationRuleEventConfig, + zNotificationRuleFunnelConfig, +]); + +export type INotificationRuleConfig = z.infer; + +export const zCreateNotificationRule = z.object({ + id: z.string().optional(), + name: z.string().min(1), + config: zNotificationRuleConfig, + integrations: z.array(z.string()), + sendToApp: z.boolean(), + sendToEmail: z.boolean(), + projectId: z.string(), +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ab1d9759..10c30f65 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -51,6 +51,9 @@ importers: '@openpanel/db': specifier: workspace:* version: link:../../packages/db + '@openpanel/integrations': + specifier: workspace:^ + version: link:../../packages/integrations '@openpanel/logger': specifier: workspace:* version: link:../../packages/logger @@ -172,6 +175,9 @@ importers: '@openpanel/db': specifier: workspace:^ version: link:../../packages/db + '@openpanel/integrations': + specifier: workspace:^ + version: link:../../packages/integrations '@openpanel/nextjs': specifier: 1.0.3 version: 1.0.3(next@14.2.1)(react-dom@18.2.0)(react@18.2.0) @@ -676,6 +682,9 @@ importers: '@openpanel/db': specifier: workspace:* version: link:../../packages/db + '@openpanel/integrations': + specifier: workspace:^ + version: link:../../packages/integrations '@openpanel/logger': specifier: workspace:* version: link:../../packages/logger @@ -862,6 +871,9 @@ importers: '@openpanel/logger': specifier: workspace:* version: link:../logger + '@openpanel/queue': + specifier: workspace:^ + version: link:../queue '@openpanel/redis': specifier: workspace:* version: link:../redis @@ -871,6 +883,9 @@ importers: '@prisma/client': specifier: ^5.1.1 version: 5.9.1(prisma@5.9.1) + prisma-json-types-generator: + specifier: ^3.1.1 + version: 3.1.1(prisma@5.9.1)(typescript@5.3.3) ramda: specifier: ^0.29.1 version: 0.29.1 @@ -906,6 +921,28 @@ importers: specifier: ^5.2.2 version: 5.3.3 + packages/integrations: + dependencies: + '@slack/bolt': + specifier: ^3.18.0 + version: 3.21.4 + '@slack/oauth': + specifier: ^3.0.0 + version: 3.0.1 + devDependencies: + '@openpanel/tsconfig': + specifier: workspace:* + version: link:../../tooling/typescript + '@openpanel/validation': + specifier: workspace:* + version: link:../validation + '@types/node': + specifier: ^18.16.0 + version: 18.19.17 + typescript: + specifier: ^5.2.2 + version: 5.3.3 + packages/logger: dependencies: '@hyperdx/node-opentelemetry': @@ -1015,7 +1052,7 @@ importers: packages/sdks/nextjs: dependencies: '@openpanel/web': - specifier: 1.0.0-local + specifier: 1.0.1-local version: link:../web next: specifier: ^12.0.0 || ^13.0.0 || ^14.0.0 @@ -1116,6 +1153,9 @@ importers: '@openpanel/db': specifier: workspace:* version: link:../db + '@openpanel/integrations': + specifier: workspace:^ + version: link:../integrations '@openpanel/redis': specifier: workspace:* version: link:../redis @@ -1359,7 +1399,7 @@ packages: '@babel/core': 7.23.9 '@babel/helper-compilation-targets': 7.23.6 '@babel/helper-plugin-utils': 7.22.5 - debug: 4.3.4 + debug: 4.3.7 lodash.debounce: 4.0.8 resolve: 1.22.8 transitivePeerDependencies: @@ -3086,7 +3126,7 @@ packages: resolution: {integrity: sha512-Txumi3td7J4A/xTTwlssKieHKTGl3j4A1tglBx72auZ49YK7ePY6XZricgIg9mnZT4xPfA+UPCUdnhRuEFDL+w==} requiresBuild: true dependencies: - tslib: 2.6.2 + tslib: 2.7.0 dev: false optional: true @@ -3516,7 +3556,7 @@ packages: rimraf: 2.7.1 sudo-prompt: 8.2.5 tmp: 0.0.33 - tslib: 2.6.2 + tslib: 2.7.0 transitivePeerDependencies: - supports-color dev: false @@ -5807,6 +5847,10 @@ packages: prisma: 5.9.1 dev: false + /@prisma/debug@5.20.0: + resolution: {integrity: sha512-oCx79MJ4HSujokA8S1g0xgZUGybD4SyIOydoHMngFYiwEwYDQ5tBQkK5XoEHuwOYDKUOKRn/J0MEymckc4IgsQ==} + dev: false + /@prisma/debug@5.9.1: resolution: {integrity: sha512-yAHFSFCg8KVoL0oRUno3m60GAjsUKYUDkQ+9BA2X2JfVR3kRVSJFc/GpQ2fSORi4pSHZR9orfM4UC9OVXIFFTA==} @@ -5829,6 +5873,12 @@ packages: '@prisma/engines-version': 5.9.0-32.23fdc5965b1e05fc54e5f26ed3de66776b93de64 '@prisma/get-platform': 5.9.1 + /@prisma/generator-helper@5.20.0: + resolution: {integrity: sha512-37Aibw0wVRQgQVtCdNAIN71YFnSQfvetok7vd95KKkYkQRbEx94gsvPDpyN9Mw7p3IwA3nFgPfLc3jBRztUkKw==} + dependencies: + '@prisma/debug': 5.20.0 + dev: false + /@prisma/get-platform@5.9.1: resolution: {integrity: sha512-6OQsNxTyhvG+T2Ksr8FPFpuPeL4r9u0JF0OZHUBI/Uy9SS43sPyAIutt4ZEAyqWQt104ERh70EZedkHZKsnNbg==} dependencies: @@ -7735,6 +7785,134 @@ packages: '@sinonjs/commons': 3.0.1 dev: false + /@slack/bolt@3.21.4: + resolution: {integrity: sha512-4PqOuHXcVt8KxjKiLdLIqZp8285zdiYLj7HrrKvVHnUNbkD0l16HZxtMfIEe07REQ+vmM1mrqCiZqe9dPAMucA==} + engines: {node: '>=14.21.3', npm: '>=6.14.18'} + dependencies: + '@slack/logger': 4.0.0 + '@slack/oauth': 2.6.3 + '@slack/socket-mode': 1.3.6 + '@slack/types': 2.14.0 + '@slack/web-api': 6.12.1 + '@types/express': 4.17.21 + '@types/promise.allsettled': 1.0.6 + '@types/tsscmp': 1.0.2 + axios: 1.7.7 + express: 4.19.2 + path-to-regexp: 8.2.0 + promise.allsettled: 1.0.7 + raw-body: 2.5.2 + tsscmp: 1.0.6 + transitivePeerDependencies: + - bufferutil + - debug + - supports-color + - utf-8-validate + dev: false + + /@slack/logger@3.0.0: + resolution: {integrity: sha512-DTuBFbqu4gGfajREEMrkq5jBhcnskinhr4+AnfJEk48zhVeEv3XnUKGIX98B74kxhYsIMfApGGySTn7V3b5yBA==} + engines: {node: '>= 12.13.0', npm: '>= 6.12.0'} + dependencies: + '@types/node': 20.14.12 + dev: false + + /@slack/logger@4.0.0: + resolution: {integrity: sha512-Wz7QYfPAlG/DR+DfABddUZeNgoeY7d1J39OCR2jR+v7VBsB8ezulDK5szTnDDPDwLH5IWhLvXIHlCFZV7MSKgA==} + engines: {node: '>= 18', npm: '>= 8.6.0'} + dependencies: + '@types/node': 20.14.12 + dev: false + + /@slack/oauth@2.6.3: + resolution: {integrity: sha512-1amXs6xRkJpoH6zSgjVPgGEJXCibKNff9WNDijcejIuVy1HFAl1adh7lehaGNiHhTWfQkfKxBiF+BGn56kvoFw==} + engines: {node: '>=12.13.0', npm: '>=6.12.0'} + dependencies: + '@slack/logger': 3.0.0 + '@slack/web-api': 6.12.1 + '@types/jsonwebtoken': 8.5.9 + '@types/node': 20.14.12 + jsonwebtoken: 9.0.2 + lodash.isstring: 4.0.1 + transitivePeerDependencies: + - debug + dev: false + + /@slack/oauth@3.0.1: + resolution: {integrity: sha512-TuR9PI6bYKX6qHC7FQI4keMnhj45TNfSNQtTU3mtnHUX4XLM2dYLvRkUNADyiLTle2qu2rsOQtCIsZJw6H0sDA==} + engines: {node: '>=18', npm: '>=8.6.0'} + dependencies: + '@slack/logger': 4.0.0 + '@slack/web-api': 7.5.0 + '@types/jsonwebtoken': 9.0.6 + '@types/node': 20.14.12 + jsonwebtoken: 9.0.2 + lodash.isstring: 4.0.1 + transitivePeerDependencies: + - debug + dev: false + + /@slack/socket-mode@1.3.6: + resolution: {integrity: sha512-G+im7OP7jVqHhiNSdHgv2VVrnN5U7KY845/5EZimZkrD4ZmtV0P3BiWkgeJhPtdLuM7C7i6+M6h6Bh+S4OOalA==} + engines: {node: '>=12.13.0', npm: '>=6.12.0'} + dependencies: + '@slack/logger': 3.0.0 + '@slack/web-api': 6.12.1 + '@types/node': 20.14.12 + '@types/ws': 7.4.7 + eventemitter3: 5.0.1 + finity: 0.5.4 + ws: 7.5.9 + transitivePeerDependencies: + - bufferutil + - debug + - utf-8-validate + dev: false + + /@slack/types@2.14.0: + resolution: {integrity: sha512-n0EGm7ENQRxlXbgKSrQZL69grzg1gHLAVd+GlRVQJ1NSORo0FrApR7wql/gaKdu2n4TO83Sq/AmeUOqD60aXUA==} + engines: {node: '>= 12.13.0', npm: '>= 6.12.0'} + dev: false + + /@slack/web-api@6.12.1: + resolution: {integrity: sha512-dXHyHkvvziqkDdZlPRnUl/H2uvnUmdJ5B7kxiH1HIgHe18vcbUk1zjU/XCZgJFhxGeq5Zwa95Z+SbNW9mbRhtw==} + engines: {node: '>= 12.13.0', npm: '>= 6.12.0'} + dependencies: + '@slack/logger': 3.0.0 + '@slack/types': 2.14.0 + '@types/is-stream': 1.1.0 + '@types/node': 20.14.12 + axios: 1.7.7 + eventemitter3: 3.1.2 + form-data: 2.5.1 + is-electron: 2.2.2 + is-stream: 1.1.0 + p-queue: 6.6.2 + p-retry: 4.6.2 + transitivePeerDependencies: + - debug + dev: false + + /@slack/web-api@7.5.0: + resolution: {integrity: sha512-e1aRwbdnTVz0uQownF8UoyrQFdSs3uXtkPYWCpcb3fW3KuTEGvmEtVzAvj9gqNSlgpWj0o6is7AdptQCELd/rQ==} + engines: {node: '>= 18', npm: '>= 8.6.0'} + dependencies: + '@slack/logger': 4.0.0 + '@slack/types': 2.14.0 + '@types/node': 20.14.12 + '@types/retry': 0.12.0 + axios: 1.7.7 + eventemitter3: 5.0.1 + form-data: 4.0.0 + is-electron: 2.2.2 + is-stream: 2.0.1 + p-queue: 6.6.2 + p-retry: 4.6.2 + retry: 0.13.1 + transitivePeerDependencies: + - debug + dev: false + /@stablelib/base64@1.0.1: resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==} dev: false @@ -7746,14 +7924,14 @@ packages: /@swc/helpers@0.5.2: resolution: {integrity: sha512-E4KcWTpoLHqwPHLxidpOqQbcrZVgi0rsmmZXUle1jXmJfuIf/UWpczUJ7MZZ5tlxytgJXyp0w4PGkkeLiuIdZw==} dependencies: - tslib: 2.6.2 + tslib: 2.7.0 dev: false /@swc/helpers@0.5.5: resolution: {integrity: sha512-KGYxvIOXcceOAbEk4bi/dVLEK9z8sZ0uBB3Il5b1rhfClSpcX0yfRO0KmTkqR2cnQDymwLB+25ZyMzICg/cm/A==} dependencies: '@swc/counter': 0.1.3 - tslib: 2.6.2 + tslib: 2.7.0 dev: false /@t3-oss/env-core@0.7.3(typescript@5.3.3)(zod@3.22.4): @@ -8248,6 +8426,12 @@ packages: /@types/http-errors@2.0.4: resolution: {integrity: sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==} + /@types/is-stream@1.1.0: + resolution: {integrity: sha512-jkZatu4QVbR60mpIzjINmtS1ZF4a/FqdTUTBeQDVOQ2PYyidtwFKr0B5G6ERukKwliq+7mIXvxyppwzG5EgRYg==} + dependencies: + '@types/node': 20.14.12 + dev: false + /@types/istanbul-lib-coverage@2.0.6: resolution: {integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==} dev: false @@ -8272,11 +8456,16 @@ packages: resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} dev: false + /@types/jsonwebtoken@8.5.9: + resolution: {integrity: sha512-272FMnFGzAVMGtu9tkr29hRL6bZj4Zs1KZNeHLnKqAvp06tAIcarTMwOh8/8bz4FmKRcMxZhZNeUAQsNLoiPhg==} + dependencies: + '@types/node': 20.14.12 + dev: false + /@types/jsonwebtoken@9.0.6: resolution: {integrity: sha512-/5hndP5dCjloafCXns6SZyESp3Ldq7YjH3zwzwczYnjxIT0Fqzk5ROSYVGfFyczIue7IUEj8hkvLbPoLQ18vQw==} dependencies: '@types/node': 20.14.12 - dev: true /@types/katex@0.16.7: resolution: {integrity: sha512-HMwFiRujE5PjrgwHQ25+bsLJgowjGjm5Z8FVSf0N6PwgJrwxH0QxzHYDcKsTfV3wva0vzrpqMTJS2jXPr5BMEQ==} @@ -8412,6 +8601,10 @@ packages: '@types/node': 20.14.12 dev: true + /@types/promise.allsettled@1.0.6: + resolution: {integrity: sha512-wA0UT0HeT2fGHzIFV9kWpYz5mdoyLxKrTgMdZQM++5h6pYAFH73HXcQhefg24nD1yivUFEn5KU+EF4b+CXJ4Wg==} + dev: false + /@types/prop-types@15.7.11: resolution: {integrity: sha512-ga8y9v9uyeiLdpKddhxYQkxNDrfvuPrlFb0N1qnZZByvcElJaXthF1UhvCh9TLWJBEHeNtdnbysW7Y6Uq8CVng==} @@ -8466,6 +8659,10 @@ packages: '@types/node': 20.14.12 dev: true + /@types/retry@0.12.0: + resolution: {integrity: sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==} + dev: false + /@types/scheduler@0.16.8: resolution: {integrity: sha512-WZLiwShhwLRmeV6zH+GkbOFT6Z6VklCItrDioxUnv+u4Ll+8vKeFySoFyK/0ctcRpOmwAicELfmys1sDc/Rw+A==} @@ -8508,6 +8705,10 @@ packages: resolution: {integrity: sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==} dev: false + /@types/tsscmp@1.0.2: + resolution: {integrity: sha512-cy7BRSU8GYYgxjcx0Py+8lo5MthuDhlyu076KUcYzVNXL23luYgRHkMG2fIFEc6neckeh/ntP82mw+U4QjZq+g==} + dev: false + /@types/ua-parser-js@0.7.39: resolution: {integrity: sha512-P/oDfpofrdtF5xw433SPALpdSchtJmY7nsJItf8h3KXqOslkbySh8zq4dSWXH2oTjRvJ5PczVEoCZPow6GicLg==} dev: true @@ -8528,6 +8729,12 @@ packages: resolution: {integrity: sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==} dev: true + /@types/ws@7.4.7: + resolution: {integrity: sha512-JQbbmxZTZehdc2iszGKs5oC3NFnjeay7mtAWrdt7qNtAVK0g19muApzAy4bm9byz79xa2ZnO/BOBC2R8RC5Lww==} + dependencies: + '@types/node': 20.14.12 + dev: false + /@types/ws@8.5.10: resolution: {integrity: sha512-vmQSUcfalpIq0R9q7uTo2lXs6eGIpt9wtnLdMv9LVpIjCA/+ufZRozlVoVelIYixx1ugCBKDhn89vnsEGOCx9A==} dependencies: @@ -8597,7 +8804,7 @@ packages: debug: 4.3.4 globby: 11.1.0 is-glob: 4.0.3 - semver: 7.6.0 + semver: 7.6.3 tsutils: 3.21.0(typescript@5.3.3) typescript: 5.3.3 transitivePeerDependencies: @@ -8704,7 +8911,7 @@ packages: resolution: {integrity: sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA==} engines: {node: '>= 14'} dependencies: - debug: 4.3.4 + debug: 4.3.7 transitivePeerDependencies: - supports-color dev: false @@ -8942,6 +9149,18 @@ packages: es-shim-unscopables: 1.0.2 dev: false + /array.prototype.map@1.0.7: + resolution: {integrity: sha512-XpcFfLoBEAhezrrNw1V+yLXkE7M6uR7xJEsxbG6c/V9v043qurwVJB9r9UTnoSioFDoz1i1VOydpWGmJpfVZbg==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-abstract: 1.23.3 + es-array-method-boxes-properly: 1.0.0 + es-object-atoms: 1.0.0 + is-string: 1.0.7 + dev: false + /array.prototype.tosorted@1.1.3: resolution: {integrity: sha512-/DdH4TiTmOKzyQbp/eadcCVexiCb36xJg7HshYOYJnNZFDj33GEv0P7GxsynpShhq4OLYJzbGcBDkLsDt7MnNg==} dependencies: @@ -8978,7 +9197,7 @@ packages: resolution: {integrity: sha512-c27loCv9QkZinsa5ProX751khO9DJl/AcB5c2KNtA6NRvHKS0PgLfcftz72KVq504vB0Gku5s2kUZzDBvQWvHg==} engines: {node: '>=4'} dependencies: - tslib: 2.6.2 + tslib: 2.7.0 dev: false /astral-regex@1.0.0: @@ -9056,6 +9275,13 @@ packages: engines: {node: '>= 0.4'} dev: false + /available-typed-arrays@1.0.7: + resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} + engines: {node: '>= 0.4'} + dependencies: + possible-typed-array-names: 1.0.0 + dev: false + /avvio@8.3.0: resolution: {integrity: sha512-VBVH0jubFr9LdFASy/vNtm5giTrnbVquWBhT0fyizuNK2rQ7e7ONU2plZQWUNqtE1EmxFEb+kbSkFRkstiaS9Q==} dependencies: @@ -9072,6 +9298,16 @@ packages: engines: {node: '>=4'} dev: false + /axios@1.7.7: + resolution: {integrity: sha512-S4kL7XrjgBmvdGut0sN3yJxqYzrDOnivkBiN0OFs6hLiUam3UPvswUo0kqGyhqUZGEOytHyumEdXsAkgCOUf3Q==} + dependencies: + follow-redirects: 1.15.9 + form-data: 4.0.0 + proxy-from-env: 1.1.0 + transitivePeerDependencies: + - debug + dev: false + /axobject-query@3.2.1: resolution: {integrity: sha512-jsyHu61e6N4Vbz/v18DHwWYKK0bSWLqn47eeDSKPB7m8tqMHF9YJ+mhIk2lVteyZrY8tnSj/jHOv4YiTCuCJgg==} dependencies: @@ -10477,6 +10713,33 @@ packages: engines: {node: '>= 12'} dev: false + /data-view-buffer@1.0.1: + resolution: {integrity: sha512-0lht7OugA5x3iJLOWFhWK/5ehONdprk0ISXqVFn/NFrDu+cuc8iADFrGQz5BnRK7LLU3JmkbXSxaqX+/mXYtUA==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.7 + es-errors: 1.3.0 + is-data-view: 1.0.1 + dev: false + + /data-view-byte-length@1.0.1: + resolution: {integrity: sha512-4J7wRJD3ABAzr8wP+OcIcqq2dlUKp4DVflx++hs5h5ZKydWMI6/D/fAot+yh6g2tHh8fLFTvNOaVN357NvSrOQ==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.7 + es-errors: 1.3.0 + is-data-view: 1.0.1 + dev: false + + /data-view-byte-offset@1.0.0: + resolution: {integrity: sha512-t/Ygsytq+R995EJ5PZlD4Cu56sWa8InXySaViRzw9apusqsOO2bQP+SbYzAhR0pFKoB+43lYy8rWban9JSuXnA==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.7 + es-errors: 1.3.0 + is-data-view: 1.0.1 + dev: false + /date-fns@3.3.1: resolution: {integrity: sha512-y8e109LYGgoQDveiEBD3DYXKba1jWf5BA8YU1FL5Tvm0BTdEfy54WLCwnuYWZNnzzvALy/QQ4Hov+Q9RVRv+Zw==} @@ -10772,7 +11035,7 @@ packages: resolution: {integrity: sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==} dependencies: no-case: 3.0.4 - tslib: 2.6.2 + tslib: 2.7.0 dev: false /dotenv-cli@7.3.0: @@ -10989,6 +11252,58 @@ packages: which-typed-array: 1.1.14 dev: false + /es-abstract@1.23.3: + resolution: {integrity: sha512-e+HfNH61Bj1X9/jLc5v1owaLYuHdeHHSQlkhCBiTK8rBvKaULl/beGMxwrMXjpYrv4pz22BlY570vVePA2ho4A==} + engines: {node: '>= 0.4'} + dependencies: + array-buffer-byte-length: 1.0.1 + arraybuffer.prototype.slice: 1.0.3 + available-typed-arrays: 1.0.7 + call-bind: 1.0.7 + data-view-buffer: 1.0.1 + data-view-byte-length: 1.0.1 + data-view-byte-offset: 1.0.0 + es-define-property: 1.0.0 + es-errors: 1.3.0 + es-object-atoms: 1.0.0 + es-set-tostringtag: 2.0.3 + es-to-primitive: 1.2.1 + function.prototype.name: 1.1.6 + get-intrinsic: 1.2.4 + get-symbol-description: 1.0.2 + globalthis: 1.0.3 + gopd: 1.0.1 + has-property-descriptors: 1.0.2 + has-proto: 1.0.3 + has-symbols: 1.0.3 + hasown: 2.0.2 + internal-slot: 1.0.7 + is-array-buffer: 3.0.4 + is-callable: 1.2.7 + is-data-view: 1.0.1 + is-negative-zero: 2.0.3 + is-regex: 1.1.4 + is-shared-array-buffer: 1.0.3 + is-string: 1.0.7 + is-typed-array: 1.1.13 + is-weakref: 1.0.2 + object-inspect: 1.13.1 + object-keys: 1.1.1 + object.assign: 4.1.5 + regexp.prototype.flags: 1.5.2 + safe-array-concat: 1.1.2 + safe-regex-test: 1.0.3 + string.prototype.trim: 1.2.9 + string.prototype.trimend: 1.0.8 + string.prototype.trimstart: 1.0.8 + typed-array-buffer: 1.0.2 + typed-array-byte-length: 1.0.1 + typed-array-byte-offset: 1.0.2 + typed-array-length: 1.0.6 + unbox-primitive: 1.0.2 + which-typed-array: 1.1.15 + dev: false + /es-array-method-boxes-properly@1.0.0: resolution: {integrity: sha512-wd6JXUmyHmt8T5a2xreUwKcGPq6f1f+WwIJkijUqiGcJz1qqnZgP6XIK+QyIWU5lT7imeNxUll48bziG+TSYcA==} dev: false @@ -11005,6 +11320,20 @@ packages: engines: {node: '>= 0.4'} dev: false + /es-get-iterator@1.1.3: + resolution: {integrity: sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw==} + dependencies: + call-bind: 1.0.7 + get-intrinsic: 1.2.4 + has-symbols: 1.0.3 + is-arguments: 1.1.1 + is-map: 2.0.2 + is-set: 2.0.2 + is-string: 1.0.7 + isarray: 2.0.5 + stop-iteration-iterator: 1.0.0 + dev: false + /es-iterator-helpers@1.0.17: resolution: {integrity: sha512-lh7BsUqelv4KUbR5a/ZTaGGIMLCjPGPqJ6q+Oq24YP0RdyptX1uzm4vvaqzk7Zx3bpl/76YLTTDj9L7uYQ92oQ==} engines: {node: '>= 0.4'} @@ -11026,6 +11355,13 @@ packages: safe-array-concat: 1.1.0 dev: false + /es-object-atoms@1.0.0: + resolution: {integrity: sha512-MZ4iQ6JwHOBQjahnjwaC1ZtIBH+2ohjamzAO3oaHcXYup7qxjF2fixyH+Q71voWHeOkI2q/TnJao/KfXYIZWbw==} + engines: {node: '>= 0.4'} + dependencies: + es-errors: 1.3.0 + dev: false + /es-set-tostringtag@2.0.2: resolution: {integrity: sha512-BuDyupZt65P9D2D2vA/zqcI3G5xRsklm5N3xCwuiy+/vKy8i0ifdsQP1sLgO4tZDSCaQUSnmC48khknGMV3D2Q==} engines: {node: '>= 0.4'} @@ -11035,6 +11371,15 @@ packages: hasown: 2.0.1 dev: false + /es-set-tostringtag@2.0.3: + resolution: {integrity: sha512-3T8uNMC3OQTHkFUsFq8r/BwAXLHvU/9O9mE0fBc/MY5iq/8H7ncvO947LmYA6ldWw9Uh8Yhf25zu6n7nML5QWQ==} + engines: {node: '>= 0.4'} + dependencies: + get-intrinsic: 1.2.4 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + dev: false + /es-shim-unscopables@1.0.2: resolution: {integrity: sha512-J3yBRXCzDu4ULnQwxyToo/OjdMx6akgVC7K6few0a7F/0wLtmKKN7I73AH5T2836UuXRqN7Qg+IIUw/+YJksRw==} dependencies: @@ -11494,10 +11839,18 @@ packages: engines: {node: '>=6'} dev: false + /eventemitter3@3.1.2: + resolution: {integrity: sha512-tvtQIeLVHjDkJYnzf2dgVMxfuSGJeM/7UCG17TT4EumTfNtF+0nebF/4zWOIkCreAbtNqhGEboB6BWrwqNaw4Q==} + dev: false + /eventemitter3@4.0.7: resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} dev: false + /eventemitter3@5.0.1: + resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==} + dev: false + /events@3.3.0: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} engines: {node: '>=0.8.x'} @@ -12017,6 +12370,10 @@ packages: micromatch: 4.0.5 dev: false + /finity@0.5.4: + resolution: {integrity: sha512-3l+5/1tuw616Lgb0QBimxfdd2TqaDGpfCBpfX6EqtFmqUV3FtQnVEX4Aa62DagYEqnsTIjZcTfbq9msDbXYgyA==} + dev: false + /flag-icons@7.1.0: resolution: {integrity: sha512-AH4v++19bpC5P3Wh767top4wylJYJCWkFnvNiDqGHDxqSqdMZ49jpLXp8PWBHTTXaNQ+/A+QPrOwyiIGaiIhmw==} dev: false @@ -12055,6 +12412,16 @@ packages: resolution: {integrity: sha512-Rwix9pBtC1Nuy5wysTmKy+UjbDJpIfg8eHjw0rjZ1mX4GNLz1Bmd16uDpI3Gk1i70Fgcs8Csg2lPm8HULFg9DQ==} dev: false + /follow-redirects@1.15.9: + resolution: {integrity: sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + dev: false + /fontfaceobserver@2.3.0: resolution: {integrity: sha512-6FPvD/IVyT4ZlNe7Wcn5Fb/4ChigpucKYSvD6a+0iMoLn2inpo711eyIcKjmDtE5XNcgAkSH9uN/nfAeZzHEfg==} dev: false @@ -12072,6 +12439,15 @@ packages: cross-spawn: 7.0.3 signal-exit: 4.1.0 + /form-data@2.5.1: + resolution: {integrity: sha512-m21N3WOmEEURgk6B9GLOE4RuWOFf28Lhh9qGYeNlGq4VDXUlJy2th2slBNU8Gp8EzloYZOibZJ7t5ecIrFSjVA==} + engines: {node: '>= 0.12'} + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + mime-types: 2.1.35 + dev: false + /form-data@3.0.1: resolution: {integrity: sha512-RHkBKtLWUVwd7SqRIvCZMEvAMoGUp0XU+seQiZejj0COz3RI3hWP4sCv3gZWWLjJTd7rGwcsF5eKZGii0r/hbg==} engines: {node: '>= 6'} @@ -12081,6 +12457,15 @@ packages: mime-types: 2.1.35 dev: false + /form-data@4.0.0: + resolution: {integrity: sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==} + engines: {node: '>= 6'} + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + mime-types: 2.1.35 + dev: false + /format@0.2.2: resolution: {integrity: sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==} engines: {node: '>=0.4.x'} @@ -12119,7 +12504,7 @@ packages: dependencies: react: 18.2.0 react-dom: 18.2.0(react@18.2.0) - tslib: 2.6.2 + tslib: 2.7.0 optionalDependencies: '@emotion/is-prop-valid': 0.8.8 dev: false @@ -12471,7 +12856,7 @@ packages: graphql: ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 dependencies: graphql: 15.8.0 - tslib: 2.6.2 + tslib: 2.7.0 dev: false /graphql@15.8.0: @@ -12527,6 +12912,11 @@ packages: engines: {node: '>= 0.4'} dev: false + /has-proto@1.0.3: + resolution: {integrity: sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==} + engines: {node: '>= 0.4'} + dev: false + /has-symbols@1.0.3: resolution: {integrity: sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==} engines: {node: '>= 0.4'} @@ -12558,6 +12948,13 @@ packages: dependencies: function-bind: 1.1.2 + /hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + dependencies: + function-bind: 1.1.2 + dev: false + /hast-util-from-dom@5.0.0: resolution: {integrity: sha512-d6235voAp/XR3Hh5uy7aGLbM3S4KamdW0WEgOaU1YoewnuYw4HXb5eRtv9g65m/RFGEfUY1Mw4UqCc5Y8L4Stg==} dependencies: @@ -13015,6 +13412,14 @@ packages: is-decimal: 2.0.1 dev: false + /is-arguments@1.1.1: + resolution: {integrity: sha512-8Q7EARjzEnKpt/PCD7e1cgUS0a6X8u5tdSiMqXhojOdoV9TsMsiO+9VLC5vAmO8N7/GmXn7yjR8qnA6bVAEzfA==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.7 + has-tostringtag: 1.0.2 + dev: false + /is-array-buffer@3.0.4: resolution: {integrity: sha512-wcjaerHw0ydZwfhiKbXJWLDY8A7yV7KhjQOpb83hGgGfId/aQa4TOvwyzn2PuswW2gPCYEL/nEAiSVpdOj1lXw==} engines: {node: '>= 0.4'} @@ -13077,6 +13482,13 @@ packages: dependencies: hasown: 2.0.1 + /is-data-view@1.0.1: + resolution: {integrity: sha512-AHkaJrsUVW6wq6JS8y3JnM/GJF/9cf+k20+iDzlSaJrinEo5+7vRiteOSwBhHRiAyQATN1AmY4hwzxJKPmYf+w==} + engines: {node: '>= 0.4'} + dependencies: + is-typed-array: 1.1.13 + dev: false + /is-date-object@1.0.5: resolution: {integrity: sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ==} engines: {node: '>= 0.4'} @@ -13103,6 +13515,10 @@ packages: hasBin: true dev: false + /is-electron@2.2.2: + resolution: {integrity: sha512-FO/Rhvz5tuw4MCWkpMzHFKWD2LsfHzIb7i6MdPYZ/KW7AlxawyLkqdy+jPZP1WubqEADE3O4FUENlJHDfQASRg==} + dev: false + /is-extendable@0.1.1: resolution: {integrity: sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==} engines: {node: '>=0.10.0'} @@ -13181,6 +13597,11 @@ packages: engines: {node: '>= 0.4'} dev: false + /is-negative-zero@2.0.3: + resolution: {integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==} + engines: {node: '>= 0.4'} + dev: false + /is-number-object@1.0.7: resolution: {integrity: sha512-k1U0IRzLMo7ZlYIfzRu23Oh6MiIFasgpb9X76eqfFZAqwH44UI4KTBvBYIZ1dSL9ZzChTB9ShHfLkR4pdW5krQ==} engines: {node: '>= 0.4'} @@ -13248,6 +13669,13 @@ packages: call-bind: 1.0.7 dev: false + /is-shared-array-buffer@1.0.3: + resolution: {integrity: sha512-nA2hv5XIhLR3uVzDDfCIknerhx8XUKnstuOERPNNIinXG7v9u+ohXF67vxm4TPTEPU6lm61ZkwP3c9PCB97rhg==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.7 + dev: false + /is-ssh@1.4.0: resolution: {integrity: sha512-x7+VxdxOdlV3CYpjvRLBv5Lo9OJerlYanjwFrPR9fuGPjCiNiCzFgAWpiLAohSbsnH4ZAys3SBh+hq5rJosxUQ==} dependencies: @@ -13346,6 +13774,17 @@ packages: engines: {node: '>=0.10.0'} dev: false + /iterate-iterator@1.0.2: + resolution: {integrity: sha512-t91HubM4ZDQ70M9wqp+pcNpu8OyJ9UAtXntT/Bcsvp5tZMnz9vRa+IunKXeI8AnfZMTv0jNuVEmGeLSMjVvfPw==} + dev: false + + /iterate-value@1.0.2: + resolution: {integrity: sha512-A6fMAio4D2ot2r/TYzr4yUWrmwNdsN5xL7+HUiyACE4DXm+q8HtPcnFTp+NnW3k4N05tZ7FVYFFb2CR13NxyHQ==} + dependencies: + es-get-iterator: 1.1.3 + iterate-iterator: 1.0.2 + dev: false + /iterator.prototype@1.1.2: resolution: {integrity: sha512-DR33HMMr8EzwuRL8Y9D3u2BMj8+RqSE850jfGu59kS7tbmPLzGkZmVSfyCFSDxuZiEY6Rzt3T2NA/qU+NwVj1w==} dependencies: @@ -14054,7 +14493,7 @@ packages: /lower-case@2.0.2: resolution: {integrity: sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==} dependencies: - tslib: 2.6.2 + tslib: 2.7.0 dev: false /lowlight@1.20.0: @@ -15524,7 +15963,7 @@ packages: resolution: {integrity: sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==} dependencies: lower-case: 2.0.2 - tslib: 2.6.2 + tslib: 2.7.0 dev: false /nocache@3.0.4: @@ -15965,6 +16404,29 @@ packages: aggregate-error: 3.1.0 dev: false + /p-queue@6.6.2: + resolution: {integrity: sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==} + engines: {node: '>=8'} + dependencies: + eventemitter3: 4.0.7 + p-timeout: 3.2.0 + dev: false + + /p-retry@4.6.2: + resolution: {integrity: sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==} + engines: {node: '>=8'} + dependencies: + '@types/retry': 0.12.0 + retry: 0.13.1 + dev: false + + /p-timeout@3.2.0: + resolution: {integrity: sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==} + engines: {node: '>=8'} + dependencies: + p-finally: 1.0.0 + dev: false + /p-try@2.2.0: resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} engines: {node: '>=6'} @@ -16102,6 +16564,11 @@ packages: resolution: {integrity: sha512-JLyh7xT1kizaEvcaXOQwOc2/Yhw6KZOvPf1S8401UyLk86CU79LN3vl7ztXGm/pZ+YjoyAJ4rxmHwbkBXJX+yw==} dev: false + /path-to-regexp@8.2.0: + resolution: {integrity: sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==} + engines: {node: '>=16'} + dev: false + /path-type@4.0.0: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} @@ -16223,6 +16690,11 @@ packages: engines: {node: '>=4.0.0'} dev: false + /possible-typed-array-names@1.0.0: + resolution: {integrity: sha512-d7Uw+eZoloe0EHDIYoe+bQ5WXnGMOpmiZFTuMWCwpjzzkL2nTjcKiAk4hh8TjnGye2TwWOk3UXucZ+3rbmBa8Q==} + engines: {node: '>= 0.4'} + dev: false + /postcss-import@15.1.0(postcss@8.4.35): resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==} engines: {node: '>=14.0.0'} @@ -16376,6 +16848,20 @@ packages: engines: {node: '>=10'} dev: false + /prisma-json-types-generator@3.1.1(prisma@5.9.1)(typescript@5.3.3): + resolution: {integrity: sha512-LYVBKWcnh6SSPf6jNJuQEmByyZtj79kuxzilCF8962ViBNciBzk+pQ8qt7HR7lFUIOEN3sUeOOhJe1RuytGk6A==} + engines: {node: '>=14.0'} + hasBin: true + peerDependencies: + prisma: ^5.20 + typescript: ^5.6.2 + dependencies: + '@prisma/generator-helper': 5.20.0 + prisma: 5.9.1 + tslib: 2.7.0 + typescript: 5.3.3 + dev: false + /prisma@5.9.1: resolution: {integrity: sha512-Hy/8KJZz0ELtkw4FnG9MS9rNWlXcJhf98Z2QMqi0QiVMoS8PzsBkpla0/Y5hTlob8F3HeECYphBjqmBxrluUrQ==} engines: {node: '>=16.13'} @@ -16441,6 +16927,18 @@ packages: optional: true dev: false + /promise.allsettled@1.0.7: + resolution: {integrity: sha512-hezvKvQQmsFkOdrZfYxUxkyxl8mgFQeT259Ajj9PXdbg9VzBCWrItOev72JyWxkCD5VSSqAeHmlN3tWx4DlmsA==} + engines: {node: '>= 0.4'} + dependencies: + array.prototype.map: 1.0.7 + call-bind: 1.0.7 + define-properties: 1.2.1 + es-abstract: 1.23.3 + get-intrinsic: 1.2.4 + iterate-value: 1.0.2 + dev: false + /promise@7.3.1: resolution: {integrity: sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==} dependencies: @@ -16510,6 +17008,10 @@ packages: ipaddr.js: 1.9.1 dev: false + /proxy-from-env@1.1.0: + resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + dev: false + /pseudomap@1.0.2: resolution: {integrity: sha512-b/YwNhb8lk1Zz2+bXXpS/LK9OisiZZ1SNsSLxN1x2OXVEhW2Ckr/7mWE5vrC1ZTiJlD9g19jWszTmJsB+oEpFQ==} dev: false @@ -16862,7 +17364,7 @@ packages: '@types/react': 18.2.56 react: 18.2.0 react-style-singleton: 2.2.1(@types/react@18.2.56)(react@18.2.0) - tslib: 2.6.2 + tslib: 2.7.0 dev: false /react-remove-scroll@2.5.4(@types/react@18.2.56)(react@18.2.0): @@ -16879,7 +17381,7 @@ packages: react: 18.2.0 react-remove-scroll-bar: 2.3.4(@types/react@18.2.56)(react@18.2.0) react-style-singleton: 2.2.1(@types/react@18.2.56)(react@18.2.0) - tslib: 2.6.2 + tslib: 2.7.0 use-callback-ref: 1.3.1(@types/react@18.2.56)(react@18.2.0) use-sidecar: 1.1.2(@types/react@18.2.56)(react@18.2.0) dev: false @@ -16969,7 +17471,7 @@ packages: get-nonce: 1.0.1 invariant: 2.2.4 react: 18.2.0 - tslib: 2.6.2 + tslib: 2.7.0 dev: false /react-svg-worldmap@2.0.0-alpha.16(react-dom@18.2.0)(react@18.2.0): @@ -17115,7 +17617,7 @@ packages: ast-types: 0.15.2 esprima: 4.0.1 source-map: 0.6.1 - tslib: 2.6.2 + tslib: 2.7.0 dev: false /recharts-scale@0.4.5: @@ -17471,6 +17973,11 @@ packages: engines: {node: '>=4'} dev: false + /retry@0.13.1: + resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==} + engines: {node: '>= 4'} + dev: false + /reusify@1.0.4: resolution: {integrity: sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} @@ -17557,7 +18064,7 @@ packages: /rxjs@7.8.1: resolution: {integrity: sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==} dependencies: - tslib: 2.6.2 + tslib: 2.7.0 dev: false /sade@1.8.1: @@ -17577,6 +18084,16 @@ packages: isarray: 2.0.5 dev: false + /safe-array-concat@1.1.2: + resolution: {integrity: sha512-vj6RsCsWBCf19jIeHEfkRMw8DPiBb+DMXklQ/1SGDHOMlHdPUkZXFQ2YdplS23zESTijAcurb1aSgJA3AgMu1Q==} + engines: {node: '>=0.4'} + dependencies: + call-bind: 1.0.7 + get-intrinsic: 1.2.4 + has-symbols: 1.0.3 + isarray: 2.0.5 + dev: false + /safe-buffer@5.1.2: resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} dev: false @@ -17908,7 +18425,7 @@ packages: resolution: {integrity: sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==} dependencies: dot-case: 3.0.4 - tslib: 2.6.2 + tslib: 2.7.0 dev: false /snakecase-keys@5.4.4: @@ -18051,6 +18568,13 @@ packages: resolution: {integrity: sha512-JPbdCEQLj1w5GilpiHAx3qJvFndqybBysA3qUOnznweH4QbNYUsW/ea8QzSrnh0vNsezMMw5bcVool8lM0gwzg==} dev: false + /stop-iteration-iterator@1.0.0: + resolution: {integrity: sha512-iCGQj+0l0HOdZ2AEeBADlsRC+vsnDsZsbdSiH1yNSjcfKM7fdpCMfqAL/dwF5BLiw/XhRft/Wax6zQbhq2BcjQ==} + engines: {node: '>= 0.4'} + dependencies: + internal-slot: 1.0.7 + dev: false + /stream-buffers@2.2.0: resolution: {integrity: sha512-uyQK/mx5QjHun80FLJTfaWE7JtwfRMKBLkMne6udYOmvH0CawotVa7TfgYHzAnpphn4+TweIx1QKMnRIbipmUg==} engines: {node: '>= 0.10.0'} @@ -18104,6 +18628,16 @@ packages: es-abstract: 1.22.4 dev: false + /string.prototype.trim@1.2.9: + resolution: {integrity: sha512-klHuCNxiMZ8MlsOihJhJEBJAiMVqU3Z2nEXWfWnIqjN0gEFS9J9+IxKozWWtQGcgoa1WUZzLjKPTr4ZHNFTFxw==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-abstract: 1.23.3 + es-object-atoms: 1.0.0 + dev: false + /string.prototype.trimend@1.0.7: resolution: {integrity: sha512-Ni79DqeB72ZFq1uH/L6zJ+DKZTkOtPIHovb3YZHQViE+HDouuU4mBrLOLDn5Dde3RF8qw5qVETEjhu9locMLvA==} dependencies: @@ -18112,6 +18646,14 @@ packages: es-abstract: 1.22.4 dev: false + /string.prototype.trimend@1.0.8: + resolution: {integrity: sha512-p73uL5VCHCO2BZZ6krwwQE3kCzM7NKmis8S//xEC6fQonchbum4eP6kR4DLEjQFO3Wnj3Fuo8NM0kOSjVdHjZQ==} + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-object-atoms: 1.0.0 + dev: false + /string.prototype.trimstart@1.0.7: resolution: {integrity: sha512-NGhtDFu3jCEm7B4Fy0DpLewdJQOZcQ0rGbwQ/+stjnrp2i+rlKeCvos9hOIeCmqwratM47OBxY7uFZzjxHXmrg==} dependencies: @@ -18120,6 +18662,15 @@ packages: es-abstract: 1.22.4 dev: false + /string.prototype.trimstart@1.0.8: + resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-object-atoms: 1.0.0 + dev: false + /string_decoder@1.1.1: resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} dependencies: @@ -18627,6 +19178,10 @@ packages: requiresBuild: true dev: false + /tslib@2.7.0: + resolution: {integrity: sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==} + dev: false + /tsscmp@1.0.6: resolution: {integrity: sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==} engines: {node: '>=0.6.x'} @@ -18743,6 +19298,15 @@ packages: is-typed-array: 1.1.13 dev: false + /typed-array-buffer@1.0.2: + resolution: {integrity: sha512-gEymJYKZtKXzzBzM4jqa9w6Q1Jjm7x2d+sh19AdsD4wqnMPDYyvwpsIc2Q/835kHuo3BEQ7CjelGhfTsoBb2MQ==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.7 + es-errors: 1.3.0 + is-typed-array: 1.1.13 + dev: false + /typed-array-byte-length@1.0.0: resolution: {integrity: sha512-Or/+kvLxNpeQ9DtSydonMxCx+9ZXOswtwJn17SNLvhptaXYDJvkFFP5zbfU/uLmvnBJlI4yrnXRxpdWH/M5tNA==} engines: {node: '>= 0.4'} @@ -18753,6 +19317,17 @@ packages: is-typed-array: 1.1.13 dev: false + /typed-array-byte-length@1.0.1: + resolution: {integrity: sha512-3iMJ9q0ao7WE9tWcaYKIptkNBuOIcZCCT0d4MRvuuH88fEoEH62IuQe0OtraD3ebQEoTRk8XCBoknUNc1Y67pw==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.7 + for-each: 0.3.3 + gopd: 1.0.1 + has-proto: 1.0.3 + is-typed-array: 1.1.13 + dev: false + /typed-array-byte-offset@1.0.0: resolution: {integrity: sha512-RD97prjEt9EL8YgAgpOkf3O4IF9lhJFr9g0htQkm0rchFp/Vx7LW5Q8fSXXub7BXAODyUQohRMyOc3faCPd0hg==} engines: {node: '>= 0.4'} @@ -18764,6 +19339,18 @@ packages: is-typed-array: 1.1.13 dev: false + /typed-array-byte-offset@1.0.2: + resolution: {integrity: sha512-Ous0vodHa56FviZucS2E63zkgtgrACj7omjwd/8lTEMEPFFyjfixMZ1ZXenpgCFBBt4EC1J2XsyVS2gkG0eTFA==} + engines: {node: '>= 0.4'} + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.7 + for-each: 0.3.3 + gopd: 1.0.1 + has-proto: 1.0.3 + is-typed-array: 1.1.13 + dev: false + /typed-array-length@1.0.4: resolution: {integrity: sha512-KjZypGq+I/H7HI5HlOoGHkWUUGq+Q0TPhQurLbyrVrvnKTBgzLhIJ7j6J/XTQOi0d1RjyZ0wdas8bKs2p0x3Ng==} dependencies: @@ -18772,6 +19359,18 @@ packages: is-typed-array: 1.1.13 dev: false + /typed-array-length@1.0.6: + resolution: {integrity: sha512-/OxDN6OtAk5KBpGb28T+HZc2M+ADtvRxXrKKbUwtsLgdoxgX13hyy7ek6bFRl5+aBs2yZzB0c4CnQfAtVypW/g==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.7 + for-each: 0.3.3 + gopd: 1.0.1 + has-proto: 1.0.3 + is-typed-array: 1.1.13 + possible-typed-array-names: 1.0.0 + dev: false + /typed-function@4.1.1: resolution: {integrity: sha512-Pq1DVubcvibmm8bYcMowjVnnMwPVMeh0DIdA8ad8NZY2sJgapANJmiigSUwlt+EgXxpfIv8MWrQXTIzkfYZLYQ==} engines: {node: '>= 14'} @@ -19063,7 +19662,7 @@ packages: dependencies: '@types/react': 18.2.56 react: 18.2.0 - tslib: 2.6.2 + tslib: 2.7.0 dev: false /use-sidecar@1.1.2(@types/react@18.2.56)(react@18.2.0): @@ -19079,7 +19678,7 @@ packages: '@types/react': 18.2.56 detect-node-es: 1.1.0 react: 18.2.0 - tslib: 2.6.2 + tslib: 2.7.0 dev: false /use-sync-external-store@1.2.0(react@18.2.0): @@ -19344,6 +19943,17 @@ packages: has-tostringtag: 1.0.2 dev: false + /which-typed-array@1.1.15: + resolution: {integrity: sha512-oV0jmFtUky6CXfkqehVvBP/LSWJ2sy4vWMioiENyJLePrBO/yKyV9OyJySfAKosh+RYkIl5zJCNZ8/4JncrpdA==} + engines: {node: '>= 0.4'} + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.7 + for-each: 0.3.3 + gopd: 1.0.1 + has-tostringtag: 1.0.2 + dev: false + /which@1.3.1: resolution: {integrity: sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==} hasBin: true