feature(dashboard): add integrations and notifications

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

View File

@@ -4,12 +4,11 @@ import superjson from 'superjson';
import type * as WebSocket from 'ws';
import { getSuperJson } from '@openpanel/common';
import type { IServiceEvent } from '@openpanel/db';
import type { IServiceEvent, Notification } from '@openpanel/db';
import {
TABLE_NAMES,
getEvents,
getLiveVisitors,
getProfileById,
getProfileByIdCached,
transformMinimalEvent,
} from '@openpanel/db';
@@ -169,3 +168,77 @@ export async function wsProjectEvents(
getRedisSub().off('message', message as any);
});
}
export async function wsProjectNotifications(
connection: {
socket: WebSocket;
},
req: FastifyRequest<{
Params: {
projectId: string;
};
Querystring: {
token?: string;
};
}>,
) {
const { params, query } = req;
if (!query.token) {
connection.socket.send('No token provided');
connection.socket.close();
return;
}
const subscribeToEvent = 'notification';
const decoded = validateClerkJwt(query.token);
const userId = decoded?.sub;
const access = await getProjectAccess({
userId: userId!,
projectId: params.projectId,
});
if (!access) {
connection.socket.send('No access');
connection.socket.close();
return;
}
getRedisSub().subscribe(subscribeToEvent);
const message = async (channel: string, message: string) => {
const notification = getSuperJson<Notification>(message);
if (notification?.projectId === params.projectId) {
connection.socket.send(superjson.stringify(notification));
}
};
getRedisSub().on('message', message as any);
connection.socket.on('close', () => {
getRedisSub().unsubscribe(subscribeToEvent);
getRedisSub().off('message', message as any);
});
}
export async function wsIntegrationsSlack(
connection: {
socket: WebSocket;
},
req: FastifyRequest<{
Querystring: {
organizationId?: string;
};
}>,
) {
const subscribeToEvent = 'integrations:slack';
getRedisSub().subscribe(subscribeToEvent);
const onMessage = (channel: string, message: string) => {
connection.socket.send(JSON.stringify('ok'));
};
getRedisSub().on('message', onMessage);
connection.socket.on('close', () => {
getRedisSub().unsubscribe(subscribeToEvent);
getRedisSub().off('message', onMessage);
});
}

View File

@@ -1,9 +1,15 @@
import type { WebhookEvent } from '@clerk/fastify';
import { AccessLevel, db } from '@openpanel/db';
import {
sendSlackNotification,
slackInstaller,
} from '@openpanel/integrations/src/slack';
import { getRedisPub } from '@openpanel/redis';
import { zSlackAuthResponse } from '@openpanel/validation';
import type { FastifyReply, FastifyRequest } from 'fastify';
import { pathOr } from 'ramda';
import { Webhook } from 'svix';
import { AccessLevel, db } from '@openpanel/db';
import { z } from 'zod';
if (!process.env.CLERK_SIGNING_SECRET) {
throw new Error('CLERK_SIGNING_SECRET is required');
@@ -152,3 +158,98 @@ export async function clerkWebhook(
reply.send({ success: true });
}
const paramsSchema = z.object({
code: z.string(),
state: z.string(),
});
const metadataSchema = z.object({
organizationId: z.string(),
integrationId: z.string(),
});
export async function slackWebhook(
request: FastifyRequest<{
Querystring: WebhookEvent;
}>,
reply: FastifyReply,
) {
const parsedParams = paramsSchema.safeParse(request.query);
if (!parsedParams.success) {
request.log.error('Invalid params', parsedParams);
return reply.status(400).send({ error: 'Invalid params' });
}
const veryfiedState = await slackInstaller.stateStore?.verifyStateParam(
new Date(),
parsedParams.data.state,
);
const parsedMetadata = metadataSchema.safeParse(
JSON.parse(veryfiedState?.metadata ?? '{}'),
);
if (!parsedMetadata.success) {
request.log.error('Invalid metadata', parsedMetadata.error.errors);
return reply.status(400).send({ error: 'Invalid metadata' });
}
const slackOauthAccessUrl = [
'https://slack.com/api/oauth.v2.access',
`?client_id=${process.env.SLACK_CLIENT_ID}`,
`&client_secret=${process.env.SLACK_CLIENT_SECRET}`,
`&code=${parsedParams.data.code}`,
`&redirect_uri=${process.env.SLACK_OAUTH_REDIRECT_URL}`,
].join('');
try {
const response = await fetch(slackOauthAccessUrl);
const json = await response.json();
const parsedJson = zSlackAuthResponse.safeParse(json);
if (!parsedJson.success) {
request.log.error(
{
zod: parsedJson,
json,
},
'Failed to parse slack auth response',
);
return reply
.status(400)
.header('Content-Type', 'text/html')
.send('<h1>Failed to exchange code for token</h1>');
}
// Send a notification first to confirm the connection
await sendSlackNotification({
webhookUrl: parsedJson.data.incoming_webhook.url,
message:
'👋 Hello. You have successfully connected OpenPanel.dev to your Slack workspace.',
});
await db.integration.update({
where: {
id: parsedMetadata.data.integrationId,
organizationId: parsedMetadata.data.organizationId,
},
data: {
config: {
type: 'slack',
...parsedJson.data,
},
},
});
getRedisPub().publish('integrations:slack', 'ok');
reply.send({ success: true });
} catch (err) {
request.log.error(err);
return reply
.status(500)
.header('Content-Type', 'text/html')
.send('<h1>Failed to exchange code for token</h1>');
}
}

View File

@@ -27,6 +27,16 @@ const liveRouter: FastifyPluginCallback = (fastify, opts, done) => {
{ websocket: true },
controller.wsProjectEvents,
);
fastify.get(
'/notifications/:projectId',
{ websocket: true },
controller.wsProjectNotifications,
);
fastify.get(
'/integrations/slack',
{ websocket: true },
controller.wsIntegrationsSlack,
);
done();
});

View File

@@ -7,6 +7,11 @@ const webhookRouter: FastifyPluginCallback = (fastify, opts, done) => {
url: '/clerk',
handler: controller.clerkWebhook,
});
fastify.route({
method: 'GET',
url: '/slack',
handler: controller.slackWebhook,
});
done();
};