feature(dashboard): add integrations and notifications
@@ -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
|
||||
|
||||
@@ -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:*",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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>');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:^",
|
||||
|
||||
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 15 KiB |
@@ -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 |
|
Before Width: | Height: | Size: 7.0 KiB |
@@ -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 |
@@ -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={{
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
|
Before Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
@@ -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>
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { LucideIcon } from 'lucide-react';
|
||||
interface FullPageEmptyStateProps {
|
||||
icon?: LucideIcon;
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
144
apps/dashboard/src/components/integrations/integration-card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
49
apps/dashboard/src/components/integrations/integrations.tsx
Normal 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>
|
||||
),
|
||||
},
|
||||
];
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
131
apps/dashboard/src/components/notifications/rule-card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
140
apps/dashboard/src/components/notifications/table/columns.tsx
Normal 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;
|
||||
}
|
||||
64
apps/dashboard/src/components/notifications/table/index.tsx
Normal 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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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={{
|
||||
|
||||
28
apps/dashboard/src/components/ping.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
5
apps/dashboard/src/components/skeleton.tsx
Normal 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)} />;
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
101
apps/dashboard/src/modals/add-integration.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
308
apps/dashboard/src/modals/add-notification-rule.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 7.0 KiB |
|
Before Width: | Height: | Size: 9.8 KiB |
|
Before Width: | Height: | Size: 15 KiB |
BIN
apps/public/public/logo.jpg
Normal file
|
After Width: | Height: | Size: 87 KiB |
@@ -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 |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
@@ -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
|
||||
|
||||
@@ -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:*",
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
71
apps/worker/src/jobs/notification.ts
Normal 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',
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
export function stripTrailingSlash(url: string) {
|
||||
return url.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
export function stripLeadingAndTrailingSlashes(url: string) {
|
||||
return url.replace(/^[/]+|[/]+$/g, '');
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "notifications" ADD COLUMN "payload" JSONB;
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
314
packages/db/src/services/notification.service.ts
Normal 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);
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,6 @@
|
||||
},
|
||||
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
|
||||
},
|
||||
"include": ["."],
|
||||
"include": [".", "./src/types.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
|
||||
2
packages/integrations/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// Empty, import directly from src/
|
||||
export {};
|
||||
18
packages/integrations/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
34
packages/integrations/src/discord.ts
Normal 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',
|
||||
});
|
||||
}
|
||||
50
packages/integrations/src/slack.ts
Normal 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,
|
||||
}),
|
||||
});
|
||||
}
|
||||
12
packages/integrations/tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "@openpanel/tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"],
|
||||
},
|
||||
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
|
||||
},
|
||||
"include": ["."],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
137
packages/trpc/src/routers/integration.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
}),
|
||||
});
|
||||
150
packages/trpc/src/routers/notification.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
}),
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||