feature(dashboard): add integrations and notifications

This commit is contained in:
Carl-Gerhard Lindesvärd
2024-10-02 22:12:05 +02:00
parent d920f6951c
commit f65a633403
94 changed files with 3692 additions and 127 deletions

View File

@@ -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

View File

@@ -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:*",

View File

@@ -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<Notification>(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);
});
}

View File

@@ -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('<h1>Failed to exchange code for token</h1>');
}
// 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('<h1>Failed to exchange code for token</h1>');
}
}

View File

@@ -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();
});

View File

@@ -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();
};

View File

@@ -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;
}

View File

@@ -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

View File

@@ -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:^",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -1,15 +0,0 @@
<svg width="824" height="824" viewBox="0 0 824 824" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="824" height="824" fill="#007BFF" fill-opacity="0.3"/>
<path d="M0 0L824 0V824H0L0 0Z" fill="url(#paint0_linear_0_131)"/>
<path d="M436 220H508C520.73 220 532.939 225.057 541.941 234.059C550.943 243.061 556 255.27 556 268V604" stroke="#BED2FF" stroke-width="24" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M172 604H244" stroke="#BED2FF" stroke-width="24" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M436 604H652" stroke="#BED2FF" stroke-width="24" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M364 412V412.24" stroke="white" stroke-width="24" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M436 233.488V621.256C435.999 624.902 435.168 628.499 433.569 631.775C431.97 635.052 429.646 637.92 426.772 640.164C423.899 642.408 420.553 643.968 416.987 644.726C413.421 645.483 409.729 645.418 406.192 644.536L244 604V257.488C244.002 246.784 247.581 236.388 254.169 227.952C260.757 219.516 269.976 213.524 280.36 210.928L376.36 186.928C383.434 185.16 390.818 185.026 397.951 186.538C405.084 188.05 411.779 191.167 417.528 195.652C423.277 200.138 427.928 205.874 431.129 212.426C434.329 218.978 435.995 226.196 436 233.488Z" stroke="white" stroke-width="24" stroke-linecap="round" stroke-linejoin="round"/>
<defs>
<linearGradient id="paint0_linear_0_131" x1="353" y1="93.0001" x2="528" y2="747.5" gradientUnits="userSpaceOnUse">
<stop stop-color="#2563EB"/>
<stop offset="1" stop-color="#1D54CD"/>
</linearGradient>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 7.0 KiB

View File

@@ -1,5 +1,6 @@
<svg width="278" height="278" viewBox="0 0 278 278" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="278" height="278" rx="20" fill="#2664EB"/>
<path d="M148.959 203H128.873C128.291 203 128 202.698 128 202.095L128.349 74.7242C128.349 74.2414 128.582 74 129.048 74H163.456C174.402 74 183.048 77.4702 189.394 84.4105C195.798 91.2905 199 100.675 199 112.564C199 121.255 197.341 128.829 194.022 135.286C190.645 141.684 186.279 146.632 180.923 150.133C175.566 153.633 169.744 155.383 163.456 155.383H149.833V202.095C149.833 202.698 149.542 203 148.959 203ZM163.456 95.9979L149.833 96.179V132.933H163.456C167.241 132.933 170.53 131.062 173.325 127.32C176.119 123.518 177.517 118.599 177.517 112.564C177.517 107.736 176.265 103.783 173.761 100.705C171.258 97.567 167.823 95.9979 163.456 95.9979Z" fill="white" fill-opacity="0.9"/>
<path d="M114.47 203C108.074 203 102.177 201.36 96.7791 198.079C91.4395 194.798 87.1267 190.434 83.8408 184.986C80.6136 179.479 79 173.445 79 166.884L79.176 109.853C79.176 103.174 80.7896 97.1696 84.0169 91.8386C87.1854 86.4489 91.4688 82.143 96.8671 78.921C102.265 75.6403 108.133 74 114.47 74C121.042 74 126.939 75.611 132.161 78.8331C137.442 82.0552 141.667 86.3903 144.835 91.8386C148.063 97.2282 149.676 103.233 149.676 109.853L149.852 166.884C149.852 173.445 148.268 179.45 145.099 184.898C141.872 190.405 137.589 194.798 132.249 198.079C126.91 201.36 120.983 203 114.47 203ZM114.47 181.295C118.108 181.295 121.277 179.83 123.976 176.901C126.675 173.913 128.025 170.574 128.025 166.884L127.848 109.853C127.848 105.869 126.587 102.501 124.064 99.7473C121.541 96.9939 118.343 95.6172 114.47 95.6172C110.774 95.6172 107.605 96.9646 104.965 99.6594C102.324 102.354 101.004 105.752 101.004 109.853V166.884C101.004 170.809 102.324 174.206 104.965 177.077C107.605 179.889 110.774 181.295 114.47 181.295Z" fill="white" fill-opacity="0.9"/>
<svg width="1000" height="1000" viewBox="0 0 1000 1000" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="1000" height="1000" rx="100" fill="#2564EB"/>
<rect x="548.075" y="287.946" width="129.343" height="427.822" rx="64.6715" fill="white"/>
<rect x="747.064" y="287.946" width="129.343" height="218.886" rx="64.6715" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M300.392 283.344C202.279 283.344 122.742 362.881 122.742 460.994V533.594C122.742 631.708 202.279 711.244 300.392 711.244C398.506 711.244 478.042 631.708 478.042 533.594V460.994C478.042 362.881 398.506 283.344 300.392 283.344ZM300.714 387.844C264.997 387.844 236.042 416.799 236.042 452.516V542.058C236.042 577.775 264.997 606.73 300.714 606.73C336.431 606.73 365.385 577.776 365.385 542.058V452.516C365.385 416.799 336.431 387.844 300.714 387.844Z" fill="white"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 865 B

View File

@@ -81,7 +81,7 @@ export function RealtimeLiveHistogram({
{staticArray.map((percent, i) => (
<div
key={i as number}
className="flex-1 animate-pulse rounded bg-def-200"
className="flex-1 animate-pulse rounded-sm bg-def-200"
style={{ height: `${percent}%` }}
/>
))}
@@ -101,7 +101,7 @@ export function RealtimeLiveHistogram({
<TooltipTrigger asChild>
<div
className={cn(
'flex-1 rounded transition-all ease-in-out hover:scale-110',
'flex-1 rounded-sm transition-all ease-in-out hover:scale-110',
minute.count === 0 ? 'bg-def-200' : 'bg-highlight',
)}
style={{

View File

@@ -0,0 +1,37 @@
import { ActiveIntegrations } from '@/components/integrations/active-integrations';
import { AllIntegrations } from '@/components/integrations/all-integrations';
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(['installed', 'available'])
.withDefault('available')
.parseServerSide(searchParams.tab);
return (
<Padding>
<PageTabs className="mb-4">
<PageTabsLink href="?tab=available" isActive={tab === 'available'}>
Available
</PageTabsLink>
<PageTabsLink href="?tab=installed" isActive={tab === 'installed'}>
Installed
</PageTabsLink>
</PageTabs>
{tab === 'installed' && <ActiveIntegrations />}
{tab === 'available' && <AllIntegrations />}
</Padding>
);
}

View File

@@ -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 (
<Padding>
<PageTabs className="mb-4">
<PageTabsLink
href="?tab=notifications"
isActive={tab === 'notifications'}
>
Notifications
</PageTabsLink>
<PageTabsLink href="?tab=rules" isActive={tab === 'rules'}>
Rules
</PageTabsLink>
</PageTabs>
{tab === 'notifications' && <Notifications />}
{tab === 'rules' && <NotificationRules />}
</Padding>
);
}

View File

@@ -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(

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -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 }) {
<QueryClientProvider client={queryClient}>
<TooltipProvider delayDuration={200}>
{children}
<NotificationProvider />
<Toaster />
<ModalProvider />
</TooltipProvider>

View File

@@ -5,7 +5,7 @@ import type { LucideIcon } from 'lucide-react';
interface FullPageEmptyStateProps {
icon?: LucideIcon;
title: string;
children: React.ReactNode;
children?: React.ReactNode;
className?: string;
}

View File

@@ -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 (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 auto-rows-auto">
{isLoading && (
<>
<IntegrationCardSkeleton />
<IntegrationCardSkeleton />
<IntegrationCardSkeleton />
</>
)}
{!isLoading && data.length === 0 && (
<IntegrationCard
icon={
<IntegrationCardLogo className="bg-def-200 text-foreground">
<BoxSelectIcon className="size-10" strokeWidth={1} />
</IntegrationCardLogo>
}
name="No integrations yet"
description="Integrations allow you to connect your systems to OpenPanel. You can add them in the available integrations section."
/>
)}
<AnimatePresence mode="popLayout">
{data.map((item) => {
return (
<motion.div key={item.id} layout="position">
<IntegrationCard {...item.integration} name={item.name}>
<IntegrationCardFooter className="row justify-between items-center">
<PingBadge>Connected</PingBadge>
<div className="row gap-2">
<Button
variant="ghost"
className="text-destructive"
onClick={() => {
showConfirm({
title: `Delete ${item.name}?`,
text: 'This action cannot be undone.',
onConfirm: () => {
deletion.mutate({
id: item.id,
});
},
});
}}
>
Delete
</Button>
<Button
variant="ghost"
onClick={() => {
pushModal('AddIntegration', {
id: item.id,
type: item.config.type,
});
}}
>
Edit
</Button>
</div>
</IntegrationCardFooter>
</IntegrationCard>
</motion.div>
);
})}
</AnimatePresence>
</div>
);
}

View File

@@ -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 (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
{INTEGRATIONS.map((integration) => (
<IntegrationCard
key={integration.name}
icon={integration.icon}
name={integration.name}
description={integration.description}
>
<IntegrationCardFooter className="row justify-end">
<Button
variant="outline"
onClick={() => {
pushModal('AddIntegration', {
type: integration.type,
});
}}
>
<PlugIcon className="size-4 mr-2" />
Connect
</Button>
</IntegrationCardFooter>
</IntegrationCard>
))}
</div>
);
}

View File

@@ -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<typeof zCreateDiscordIntegration>;
export function DiscordIntegrationForm({
defaultValues,
onSuccess,
}: {
defaultValues?: RouterOutputs['integration']['get'];
onSuccess: () => void;
}) {
const { organizationId } = useAppParams();
const form = useForm<IForm>({
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 (
<form
onSubmit={form.handleSubmit(handleSubmit, handleError)}
className="col gap-4"
>
<InputWithLabel
label="Name"
{...form.register('name')}
error={form.formState.errors.name?.message}
/>
<InputWithLabel
label="Discord Webhook URL"
{...form.register('config.url')}
error={path(['config', 'url', 'message'], form.formState.errors)}
/>
<div className="row gap-4">
<Button type="button" variant="outline" onClick={handleTest}>
Test connection
</Button>
<Button type="submit" className="flex-1">
Create
</Button>
</div>
</form>
);
}

View File

@@ -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<typeof zCreateSlackIntegration>;
export function SlackIntegrationForm({
defaultValues,
onSuccess,
}: {
defaultValues?: RouterOutputs['integration']['get'];
onSuccess: () => void;
}) {
const popup = useRef<Window | null>(null);
const { organizationId } = useAppParams();
const client = useQueryClient();
useWS('/live/integrations/slack', (res) => {
if (popup.current) {
popup.current.close();
}
onSuccess();
});
const form = useForm<IForm>({
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 (
<form
onSubmit={form.handleSubmit(handleSubmit, handleError)}
className="col gap-4"
>
<InputWithLabel
label="Name"
{...form.register('name')}
error={form.formState.errors.name?.message}
/>
<Button type="submit">Create</Button>
</form>
);
}

View File

@@ -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<typeof zCreateWebhookIntegration>;
export function WebhookIntegrationForm({
defaultValues,
onSuccess,
}: {
defaultValues?: RouterOutputs['integration']['get'];
onSuccess: () => void;
}) {
const { organizationId } = useAppParams();
const form = useForm<IForm>({
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 (
<form
onSubmit={form.handleSubmit(handleSubmit, handleError)}
className="col gap-4"
>
<InputWithLabel
label="Name"
{...form.register('name')}
error={form.formState.errors.name?.message}
/>
<InputWithLabel
label="URL"
{...form.register('config.url')}
error={path(['config', 'url', 'message'], form.formState.errors)}
/>
<Button type="submit">Create</Button>
</form>
);
}

View File

@@ -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 (
<div className={cn('row p-4 border-t rounded-b', className)}>
{children}
</div>
);
}
export function IntegrationCardHeader({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
return (
<div className={cn('relative row p-4 border-b rounded-t', className)}>
{children}
</div>
);
}
export function IntegrationCardHeaderButtons({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
return (
<div
className={cn(
'absolute right-4 top-0 bottom-0 row items-center gap-2',
className,
)}
>
{children}
</div>
);
}
export function IntegrationCardLogoImage({
src,
backgroundColor,
}: {
src: string;
backgroundColor: string;
}) {
return (
<IntegrationCardLogo
style={{
backgroundColor,
}}
>
<img src={src} alt="Integration Logo" />
</IntegrationCardLogo>
);
}
export function IntegrationCardLogo({
children,
className,
...props
}: {
children: React.ReactNode;
} & React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn(
'size-14 rounded overflow-hidden shrink-0 center-center',
className,
)}
{...props}
>
{children}
</div>
);
}
export function IntegrationCard({
icon,
name,
description,
children,
}: {
icon: React.ReactNode;
name: string;
description: string;
children?: React.ReactNode;
}) {
return (
<div className="card self-start">
<IntegrationCardContent
icon={icon}
name={name}
description={description}
/>
{children}
</div>
);
}
export function IntegrationCardContent({
icon,
name,
description,
}: {
icon: React.ReactNode;
name: string;
description: string;
}) {
return (
<div className="row gap-4 p-4">
{icon}
<div className="col gap-1">
<h2 className="title">{name}</h2>
<p className="text-muted-foreground leading-tight">{description}</p>
</div>
</div>
);
}
export function IntegrationCardSkeleton() {
return (
<div className="card self-start">
<div className="row gap-4 p-4">
<Skeleton className="size-14 rounded shrink-0" />
<div className="col gap-1 flex-grow">
<Skeleton className="h-5 w-1/2 mb-2" />
<Skeleton className="h-3 w-full" />
<Skeleton className="h-3 w-3/4" />
</div>
</div>
</div>
);
}

View File

@@ -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: (
<IntegrationCardLogoImage
src="https://play-lh.googleusercontent.com/mzJpTCsTW_FuR6YqOPaLHrSEVCSJuXzCljdxnCKhVZMcu6EESZBQTCHxMh8slVtnKqo"
backgroundColor="#481449"
/>
),
},
{
type: 'discord',
name: 'Discord',
description:
'Connect your Discord server to get notified when new issues are created.',
icon: (
<IntegrationCardLogoImage
src="https://static.vecteezy.com/system/resources/previews/006/892/625/non_2x/discord-logo-icon-editorial-free-vector.jpg"
backgroundColor="#5864F2"
/>
),
},
{
type: 'webhook',
name: 'Webhook',
description:
'Create a webhook to take actions in your own systems when new events are created.',
icon: (
<IntegrationCardLogo className="bg-foreground text-background">
<WebhookIcon className="size-10" />
</IntegrationCardLogo>
),
},
];

View File

@@ -13,7 +13,13 @@ export function ProjectLink({
const { organizationSlug, projectId } = useAppParams();
if (typeof props.href === 'string') {
return (
<Link {...props} href={`/${organizationSlug}/${projectId}/${props.href}`}>
<Link
{...props}
href={`/${organizationSlug}/${projectId}/${props.href.replace(
/^\//,
'',
)}`}
>
{children}
</Link>
);

View File

@@ -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<Notification>(`/live/notifications/${projectId}`, (notification) => {
toast(notification.title, {
description: notification.message,
icon: <BellIcon className="size-4" />,
});
});
return null;
}

View File

@@ -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 (
<div>
<div className="mb-2">
<Button
icon={PlusIcon}
variant="outline"
onClick={() =>
pushModal('AddNotificationRule', {
rule: undefined,
})
}
>
Add Rule
</Button>
</div>
<div className="col gap-4 w-full grid md:grid-cols-2">
{isLoading && (
<>
<IntegrationCardSkeleton />
<IntegrationCardSkeleton />
<IntegrationCardSkeleton />
</>
)}
{!isLoading && data.length === 0 && (
<IntegrationCard
icon={
<IntegrationCardLogo className="bg-def-200 text-foreground">
<BoxSelectIcon className="size-10" strokeWidth={1} />
</IntegrationCardLogo>
}
name="No integrations yet"
description="Integrations allow you to connect your systems to OpenPanel. You can add them in the available integrations section."
/>
)}
<AnimatePresence mode="popLayout">
{data.map((item) => {
return (
<motion.div key={item.id} layout="position">
<RuleCard rule={item} />
</motion.div>
);
})}
</AnimatePresence>
</div>
</div>
);
}

View File

@@ -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 <NotificationsTable query={query} />;
}

View File

@@ -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 (
<Tooltiper
disabled={!event.filters.length}
content={
<div className="col gap-2 font-mono">
{event.filters.map((filter) => (
<div key={filter.id}>
{filter.name} {filter.operator} {JSON.stringify(filter.value)}
</div>
))}
</div>
}
>
<Badge variant="outline" className="inline-flex">
{event.name}
{Boolean(event.filters.length) && (
<FilterIcon className="size-2 ml-1" />
)}
</Badge>
</Tooltiper>
);
}
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 (
<div className="row gap-2 items-baseline flex-wrap">
<div>Get notified when</div>
{rule.config.events.map((event) => (
<EventBadge key={event.id} event={event} />
))}
<div>occurs</div>
</div>
);
case 'funnel':
return (
<div className="col gap-4">
<div>Get notified when a session has completed this funnel</div>
<div className="col gap-2">
{rule.config.events.map((event, index) => (
<div
key={event.id}
className="row gap-2 items-center font-mono"
>
<ColorSquare>{index + 1}</ColorSquare>
<EventBadge key={event.id} event={event} />
</div>
))}
</div>
</div>
);
}
};
return (
<div className="card">
<IntegrationCardHeader>
<div className="title">{rule.name}</div>
</IntegrationCardHeader>
<div className="p-4 col gap-2">{renderConfig()}</div>
<IntegrationCardFooter className="row gap-2 justify-between items-center">
<div className="row gap-2 flex-wrap">
{rule.integrations.map((integration) => (
<PingBadge key={integration.id}>{integration.name}</PingBadge>
))}
</div>
<div className="row gap-2">
<Button
variant="ghost"
className="text-destructive"
onClick={() => {
showConfirm({
title: `Delete ${rule.name}?`,
text: 'This action cannot be undone.',
onConfirm: () => {
deletion.mutate({
id: rule.id,
});
},
});
}}
>
Delete
</Button>
<Button
variant="ghost"
onClick={() => {
pushModal('AddNotificationRule', {
rule,
});
}}
>
Edit
</Button>
</div>
</IntegrationCardFooter>
</div>
);
}

View File

@@ -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<RouterOutputs['notification']['list'][number]>[] = [
{
accessorKey: 'title',
header: 'Title',
cell({ row }) {
const { title, isReadAt } = row.original;
return (
<div className="row gap-2 items-center">
{isReadAt === null && <PingBadge>Unread</PingBadge>}
<span className="max-w-md truncate font-medium">{title}</span>
</div>
);
},
},
{
accessorKey: 'message',
header: 'Message',
cell({ row }) {
const { message } = row.original;
return (
<div className="inline-flex min-w-full flex-none items-center gap-2">
{message}
</div>
);
},
},
{
accessorKey: 'country',
header: 'Country',
cell({ row }) {
const { payload } = row.original;
const event = getEventFromPayload(payload);
if (!event) {
return null;
}
return (
<div className="inline-flex min-w-full flex-none items-center gap-2">
<SerieIcon name={event.country} />
<span>{event.city}</span>
</div>
);
},
},
{
accessorKey: 'os',
header: 'OS',
cell({ row }) {
const { payload } = row.original;
const event = getEventFromPayload(payload);
if (!event) {
return null;
}
return (
<div className="flex min-w-full items-center gap-2">
<SerieIcon name={event.os} />
<span>{event.os}</span>
</div>
);
},
},
{
accessorKey: 'browser',
header: 'Browser',
cell({ row }) {
const { payload } = row.original;
const event = getEventFromPayload(payload);
if (!event) {
return null;
}
return (
<div className="inline-flex min-w-full flex-none items-center gap-2">
<SerieIcon name={event.browser} />
<span>{event.browser}</span>
</div>
);
},
},
{
accessorKey: 'profile',
header: 'Profile',
cell({ row }) {
const { payload } = row.original;
const event = getEventFromPayload(payload);
if (!event) {
return null;
}
return (
<ProjectLink
href={`/profiles/${event.profileId}`}
className="inline-flex min-w-full flex-none items-center gap-2"
>
{event.profileId}
</ProjectLink>
);
},
},
{
accessorKey: 'createdAt',
header: 'Created at',
cell({ row }) {
const date = row.original.createdAt;
const rule = row.original.integration?.notificationRules[0];
return (
<div className="col gap-1">
<div>{isToday(date) ? formatTime(date) : formatDateTime(date)}</div>
{rule && (
<div className="text-sm text-muted-foreground">
Rule: {rule.name}
</div>
)}
</div>
);
},
},
];
return columns;
}

View File

@@ -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<Notification[]>;
}
| {
query: UseQueryResult<Notification[]>;
cursor: number;
setCursor: Dispatch<SetStateAction<number>>;
};
export const NotificationsTable = ({ query, ...props }: Props) => {
const columns = useColumns();
const { data, isFetching, isLoading } = query;
if (isLoading) {
return <TableSkeleton cols={columns.length} />;
}
if (data?.length === 0) {
return (
<FullPageEmptyState title="No events here" icon={GanttChartIcon}>
<p>Could not find any events</p>
{'cursor' in props && props.cursor !== 0 && (
<Button
className="mt-8"
variant="outline"
onClick={() => props.setCursor((p) => p - 1)}
>
Go to previous page
</Button>
)}
</FullPageEmptyState>
);
}
return (
<>
<DataTable data={data ?? []} columns={columns} />
{'cursor' in props && (
<Pagination
className="mt-2"
setCursor={props.setCursor}
cursor={props.cursor}
count={Number.POSITIVE_INFINITY}
take={50}
loading={isFetching}
/>
)}
</>
);
};

View File

@@ -79,7 +79,7 @@ export function OverviewLiveHistogram({
{staticArray.map((percent, i) => (
<div
key={i as number}
className="flex-1 animate-pulse rounded-t bg-def-200"
className="flex-1 animate-pulse rounded-t-sm bg-def-200"
style={{ height: `${percent}%` }}
/>
))}
@@ -99,7 +99,7 @@ export function OverviewLiveHistogram({
<TooltipTrigger asChild>
<div
className={cn(
'flex-1 rounded-t transition-all ease-in-out hover:scale-110',
'flex-1 rounded-t-sm transition-all ease-in-out hover:scale-110',
minute.count === 0 ? 'bg-def-200' : 'bg-highlight',
)}
style={{

View File

@@ -0,0 +1,28 @@
import { cn } from '@/utils/cn';
import { Badge } from './ui/badge';
export function Ping({ className }: { className?: string }) {
return (
<div className="relative">
<div className={cn('size-2 bg-emerald-500 rounded-full', className)} />
<div
className={cn(
'size-2 bg-emerald-500 rounded-full absolute inset-0 animate-ping',
className,
)}
/>
</div>
);
}
export function PingBadge({
children,
className,
}: { children: React.ReactNode; className?: string }) {
return (
<Badge variant={'outline'} className={cn('flex gap-1', className)}>
<Ping />
{children}
</Badge>
);
}

View File

@@ -57,6 +57,14 @@ export default function SettingsToggle({ className }: Props) {
<DropdownMenuItem asChild>
<ProjectLink href="/settings/references">References</ProjectLink>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<ProjectLink href="/settings/notifications">
Notifications
</ProjectLink>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<ProjectLink href="/settings/integrations">Integrations</ProjectLink>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuSub>
<DropdownMenuSubTrigger className="flex w-full items-center justify-between">

View File

@@ -0,0 +1,5 @@
import { cn } from '@/utils/cn';
export function Skeleton({ className }: { className?: string }) {
return <div className={cn('animate-pulse rounded bg-def-200', className)} />;
}

View File

@@ -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: {

View File

@@ -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 (
<Tooltip delayDuration={delayDuration}>
<TooltipTrigger asChild={asChild} className={className} onClick={onClick}>

View File

@@ -2,6 +2,7 @@ import { useParams } from 'next/navigation';
type AppParams = {
organizationSlug: string;
organizationId: string;
projectId: string;
};
@@ -10,6 +11,7 @@ export function useAppParams<T>() {
return {
...(params ?? {}),
organizationSlug: params?.organizationSlug,
organizationId: params?.organizationSlug,
projectId: params?.projectId,
} as T & AppParams;
}

View File

@@ -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

View File

@@ -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

View File

@@ -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 (
<div className="card bg-def-100">
<IntegrationCardContent {...integration} />
</div>
);
};
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 (
<WebhookIntegrationForm
defaultValues={query.data}
onSuccess={handleSuccess}
/>
);
case 'discord':
return (
<DiscordIntegrationForm
defaultValues={query.data}
onSuccess={handleSuccess}
/>
);
case 'slack':
return (
<SlackIntegrationForm
defaultValues={query.data}
onSuccess={handleSuccess}
/>
);
default:
return null;
}
};
return (
<SheetContent className="[&>button.absolute]:hidden">
<ModalHeader title="Create an integration" />
{renderCard()}
{renderForm()}
</SheetContent>
);
}

View File

@@ -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<typeof zCreateNotificationRule>;
export default function AddNotificationRule({ rule }: Props) {
const client = useQueryClient();
const { organizationId, projectId } = useAppParams();
const form = useForm<IForm>({
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<IForm> = (data) => {
mutation.mutate(data);
};
const integrationsQuery = api.integration.list.useQuery({
organizationId,
});
const integrations = integrationsQuery.data ?? [];
return (
<SheetContent className="[&>button.absolute]:hidden">
<ModalHeader title={rule ? 'Edit rule' : 'Create rule'} />
<form onSubmit={form.handleSubmit(onSubmit)} className="col gap-4">
<InputWithLabel
label="Rule name"
placeholder="Eg. Sign ups on android"
error={form.formState.errors.name?.message}
{...form.register('name')}
/>
<WithLabel
label="Type"
// @ts-expect-error
error={form.formState.errors.config?.type.message}
>
<Controller
control={form.control}
name="config.type"
render={({ field }) => (
<Combobox
{...field}
className="w-full"
placeholder="Select type"
// @ts-expect-error
error={form.formState.errors.config?.type.message}
items={[
{
label: 'Events',
value: 'events',
},
{
label: 'Funnel',
value: 'funnel',
},
]}
/>
)}
/>
</WithLabel>
<WithLabel label="Events">
<div className="col gap-2">
{eventsArray.fields.map((field, index) => {
return (
<EventField
key={field.id}
form={form}
index={index}
remove={() => eventsArray.remove(index)}
/>
);
})}
<Button
className="self-start"
variant={'outline'}
icon={PlusIcon}
onClick={() =>
eventsArray.append({
name: '',
filters: [],
segment: 'event',
})
}
>
Add event
</Button>
</div>
</WithLabel>
<Controller
control={form.control}
name="integrations"
render={({ field }) => (
<WithLabel label="Integrations">
<ComboboxAdvanced
{...field}
value={field.value ?? []}
className="w-full"
placeholder="Pick integrations"
items={integrations.map((integration) => ({
label: integration.name,
value: integration.id,
}))}
/>
</WithLabel>
)}
/>
<Button type="submit" icon={SaveIcon}>
{rule ? 'Update' : 'Create'}
</Button>
</form>
</SheetContent>
);
}
const interval: IInterval = 'day';
const range: IChartRange = 'lastMonth';
function EventField({
form,
index,
remove,
}: {
form: UseFormReturn<IForm>;
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 (
<div className="border bg-def-100 rounded">
<div className="row gap-2 items-center p-2">
<ColorSquare>{index + 1}</ColorSquare>
<Controller
control={form.control}
name={`config.events.${index}.name`}
render={({ field }) => (
<Combobox
searchable
className="flex-1"
value={field.value}
placeholder="Select event"
onChange={field.onChange}
items={eventNames.map((item) => ({
label: item.name,
value: item.name,
}))}
/>
)}
/>
<Combobox
searchable
placeholder="Select a filter"
value=""
items={properties.map((item) => ({
label: item,
value: item,
}))}
onChange={(value) => {
filtersArray.append({
id: shortId(),
name: value,
operator: 'is',
value: [],
});
}}
>
<Button variant={'outline'} icon={FilterIcon} size={'icon'} />
</Combobox>
<Button
onClick={() => {
remove();
}}
variant={'outline'}
className="text-destructive"
icon={TrashIcon}
size={'icon'}
/>
</div>
{filtersArray.fields.map((filter, index) => {
return (
<div key={filter.id} className="p-2 border-t">
<PureFilterItem
eventName={eventName}
filter={filter}
range={range}
startDate={null}
endDate={null}
interval={interval}
onRemove={() => {
filtersArray.remove(index);
}}
onChangeValue={(value) => {
filtersArray.update(index, {
...filter,
value,
});
}}
onChangeOperator={(operator) => {
filtersArray.update(index, {
...filter,
operator,
});
}}
/>
</div>
);
})}
</div>
);
}

View File

@@ -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 {

View File

@@ -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;
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

BIN
apps/public/public/logo.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

View File

@@ -1,5 +1,6 @@
<svg width="278" height="278" viewBox="0 0 278 278" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="278" height="278" rx="20" fill="#2664EB"/>
<path d="M148.959 203H128.873C128.291 203 128 202.698 128 202.095L128.349 74.7242C128.349 74.2414 128.582 74 129.048 74H163.456C174.402 74 183.048 77.4702 189.394 84.4105C195.798 91.2905 199 100.675 199 112.564C199 121.255 197.341 128.829 194.022 135.286C190.645 141.684 186.279 146.632 180.923 150.133C175.566 153.633 169.744 155.383 163.456 155.383H149.833V202.095C149.833 202.698 149.542 203 148.959 203ZM163.456 95.9979L149.833 96.179V132.933H163.456C167.241 132.933 170.53 131.062 173.325 127.32C176.119 123.518 177.517 118.599 177.517 112.564C177.517 107.736 176.265 103.783 173.761 100.705C171.258 97.567 167.823 95.9979 163.456 95.9979Z" fill="white" fill-opacity="0.9"/>
<path d="M114.47 203C108.074 203 102.177 201.36 96.7791 198.079C91.4395 194.798 87.1267 190.434 83.8408 184.986C80.6136 179.479 79 173.445 79 166.884L79.176 109.853C79.176 103.174 80.7896 97.1696 84.0169 91.8386C87.1854 86.4489 91.4688 82.143 96.8671 78.921C102.265 75.6403 108.133 74 114.47 74C121.042 74 126.939 75.611 132.161 78.8331C137.442 82.0552 141.667 86.3903 144.835 91.8386C148.063 97.2282 149.676 103.233 149.676 109.853L149.852 166.884C149.852 173.445 148.268 179.45 145.099 184.898C141.872 190.405 137.589 194.798 132.249 198.079C126.91 201.36 120.983 203 114.47 203ZM114.47 181.295C118.108 181.295 121.277 179.83 123.976 176.901C126.675 173.913 128.025 170.574 128.025 166.884L127.848 109.853C127.848 105.869 126.587 102.501 124.064 99.7473C121.541 96.9939 118.343 95.6172 114.47 95.6172C110.774 95.6172 107.605 96.9646 104.965 99.6594C102.324 102.354 101.004 105.752 101.004 109.853V166.884C101.004 170.809 102.324 174.206 104.965 177.077C107.605 179.889 110.774 181.295 114.47 181.295Z" fill="white" fill-opacity="0.9"/>
<svg width="1000" height="1000" viewBox="0 0 1000 1000" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="1000" height="1000" rx="100" fill="#2564EB"/>
<rect x="548.075" y="287.946" width="129.343" height="427.822" rx="64.6715" fill="white"/>
<rect x="747.064" y="287.946" width="129.343" height="218.886" rx="64.6715" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M300.392 283.344C202.279 283.344 122.742 362.881 122.742 460.994V533.594C122.742 631.708 202.279 711.244 300.392 711.244C398.506 711.244 478.042 631.708 478.042 533.594V460.994C478.042 362.881 398.506 283.344 300.392 283.344ZM300.714 387.844C264.997 387.844 236.042 416.799 236.042 452.516V542.058C236.042 577.775 264.997 606.73 300.714 606.73C336.431 606.73 365.385 577.776 365.385 542.058V452.516C365.385 416.799 336.431 387.844 300.714 387.844Z" fill="white"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 865 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -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

View File

@@ -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:*",

View File

@@ -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', {

View File

@@ -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: {

View File

@@ -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<EventsQueuePayloadIncomingEvent>) {
sdkVersion,
};
await checkNotificationRulesForEvent(payload);
return createEvent(payload);
}
@@ -185,6 +187,8 @@ export async function incomingEvent(job: Job<EventsQueuePayloadIncomingEvent>) {
});
}
await checkNotificationRulesForEvent(payload);
return createEvent(payload);
}

View File

@@ -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<NotificationQueuePayload>) {
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',
),
});
}
}
}
}
}

View File

@@ -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,
};

View File

@@ -47,17 +47,16 @@ export function getSafeJson<T>(str: string): T | null {
export function getSuperJson<T>(str: string): T | null {
const json = getSafeJson<T>(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<T>(str);
}
return json;
}
export function setSuperJson(str: Record<string, unknown>): string {
return superjson.stringify(str);
}
type AnyObject = Record<string, any>;
export function deepMergeObjects<T>(target: AnyObject, source: AnyObject): T {

View File

@@ -1,3 +1,7 @@
export function stripTrailingSlash(url: string) {
return url.replace(/\/+$/, '');
}
export function stripLeadingAndTrailingSlashes(url: string) {
return url.replace(/^[/]+|[/]+$/g, '');
}

View File

@@ -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';

View File

@@ -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",

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "notifications" ADD COLUMN "payload" JSONB;

View File

@@ -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")
}

View File

@@ -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;
}

View File

@@ -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,

View File

@@ -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);
}

View File

@@ -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: {

13
packages/db/src/types.ts Normal file
View File

@@ -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;
}
}

View File

@@ -7,6 +7,6 @@
},
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
},
"include": ["."],
"include": [".", "./src/types.ts"],
"exclude": ["node_modules"]
}

View File

@@ -0,0 +1,2 @@
// Empty, import directly from src/
export {};

View File

@@ -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"
}
}

View File

@@ -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](<https://openpanel.dev/>)**\nIf you can read this, your Slack webhook is functioning correctly!\n',
});
}

View File

@@ -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,
}),
});
}

View File

@@ -0,0 +1,12 @@
{
"extends": "@openpanel/tsconfig/base.json",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"],
},
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
},
"include": ["."],
"exclude": ["node_modules"]
}

View File

@@ -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';

View File

@@ -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<CronQueuePayload>('cron', {
removeOnComplete: 10,
},
});
export type NotificationQueuePayload = {
type: 'sendNotification';
payload: {
notification: Notification;
};
};
export const notificationQueue = new Queue<NotificationQueuePayload>(
'notification',
{
connection: getRedisQueue(),
defaultJobOptions: {
removeOnComplete: 10,
},
},
);

View File

@@ -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",

View File

@@ -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

View File

@@ -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,
},
});
}),
});

View File

@@ -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,
},
});
}),
});

View File

@@ -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(

View File

@@ -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<typeof zSlackConfig>;
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<typeof zWebhookConfig>;
export const zDiscordConfig = z.object({
type: z.literal('discord'),
url: z.string().url(),
});
export type IDiscordConfig = z.infer<typeof zDiscordConfig>;
export const zAppConfig = z.object({
type: z.literal('app'),
});
export type IAppConfig = z.infer<typeof zAppConfig>;
export const zEmailConfig = z.object({
type: z.literal('email'),
});
export type IEmailConfig = z.infer<typeof zEmailConfig>;
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<typeof zNotificationRuleConfig>;
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(),
});

656
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff