feature(dashboard): add integrations and notifications
This commit is contained in:
@@ -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();
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user