Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: ability to post slack upates #3744

Merged
merged 6 commits into from
Jan 30, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ Link local to upstream project so you can push migrations to staging or prod pro
yarn supabase link --project-ref $PROJECT_REF
```

Push migrations to upstream / clooud project such as staging
Push migrations to upstream / cloud project such as staging

```
yarn supabase db push
Expand Down
28 changes: 28 additions & 0 deletions apps/backend/supabase/migrations/20240129215251_top_searches.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
set check_function_bodies = off;

CREATE OR REPLACE FUNCTION public.match_weekly_search_usage()
RETURNS TABLE(query_string character varying, count bigint)
LANGUAGE plpgsql
AS $function$
begin
return query
SELECT
queries.query_string,
count(*) as count
FROM
queries
WHERE
type = 'docs-search'
AND queries.query_string != 'dsys'
AND queries.query_string != 'create a button'
AND queries.created_at >= now() - interval '1 week'
GROUP BY
queries.query_string
ORDER BY
count DESC
LIMIT 20;
end;
$function$
;


7 changes: 7 additions & 0 deletions apps/backend/supabase/schema.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,13 @@ export interface Database {
slug: string
}[]
}
match_weekly_search_usage: {
Args: Record<PropertyKey, never>
Returns: {
query_string: string
count: number
}[]
}
upsert_story_and_create_story_render: {
Args: {
_storybook_id: string
Expand Down
1 change: 1 addition & 0 deletions packages/paste-website/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
"@next/mdx": "^14.0.0",
"@octokit/core": "^5.0.1",
"@octokit/plugin-paginate-graphql": "^4.0.0",
"@slack/web-api": "^7.0.1",
"@supabase/supabase-js": "^2.36.0",
"@twilio-paste/account-switcher": "^3.0.1",
"@twilio-paste/alert": "^14.1.0",
Expand Down
84 changes: 84 additions & 0 deletions packages/paste-website/src/pages/api/post-to-slack.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { LogLevel, WebClient } from "@slack/web-api";
import type { NextApiRequest, NextApiResponse } from "next";
import Rollbar from "rollbar";

import { logger } from "../../functions-utils/logger";

const rollbar = new Rollbar({
accessToken: process.env.ROLLBAR_ACCESS_TOKEN,
captureUncaught: true,
captureUnhandledRejections: true,
});

const slackToken = process.env.SLACK_BOT_USER_OAUTH_ACCESS_TOKEN;

const client = new WebClient(slackToken, {
// LogLevel can be imported and used to make debugging simpler
logLevel: LogLevel.DEBUG,
});

const LOG_PREFIX = "[/api/post-to-slack]:";

export default async function handler(req: NextApiRequest, res: NextApiResponse): Promise<void | Response> {
logger.info(`${LOG_PREFIX} Incoming request`);

if (slackToken === undefined) {
logger.error(`${LOG_PREFIX} Slack token is undefined`);
rollbar.error(`${LOG_PREFIX} Slack token is undefined`);
res.status(500).json({
error: "Slack token is undefined",
});
return;
}

const requestData = typeof req.body === "string" ? JSON.parse(req.body) : req.body;
logger.info(`${LOG_PREFIX} Request data`, { requestData });

if (!requestData) {
res.status(500).json({
error: "No request data",
});
}

// thread is optional if you want to reply to a Slack thread
const { message, channelID, threadID } = requestData;
logger.info(`${LOG_PREFIX} User query`, { message, channelID, threadID });

if (message === undefined || message === "") {
logger.error(`${LOG_PREFIX} Message is undefined`);
rollbar.error(`${LOG_PREFIX} Message is undefined`);
res.status(500).json({
error: "Message is undefined",
});
return;
}

if (channelID === undefined || channelID === "") {
logger.error(`${LOG_PREFIX} Channel is undefined`);
rollbar.error(`${LOG_PREFIX} Channel is undefined`);
res.status(500).json({
error: "Channel is undefined",
});
return;
}

try {
const result = await client.chat.postMessage({
channel: channelID,
text: message,
username: "Paste Bot",
// eslint-disable-next-line camelcase
thread_ts: threadID,
mrkdwn: true,
});

logger.info(`${LOG_PREFIX} Request data`, { result });
res.status(200).json({ result });
} catch (error) {
logger.error(`${LOG_PREFIX} Error posting to Slack`, { error });
rollbar.error(`${LOG_PREFIX} Error posting to Slack`, { error });
res.status(500).json({
error,
});
}
}
157 changes: 157 additions & 0 deletions packages/paste-website/src/pages/api/share-search-usage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import { createClient } from "@supabase/supabase-js";
import type { NextApiRequest, NextApiResponse } from "next";
import Rollbar from "rollbar";
import { z } from "zod";

import { logger } from "../../functions-utils/logger";

const queryCountSchema = z.object({
// eslint-disable-next-line camelcase
query_string: z.string(),
count: z.number(),
});

const latestQueriesDataSchema = z.array(queryCountSchema);

const rollbar = new Rollbar({
accessToken: process.env.ROLLBAR_ACCESS_TOKEN,
captureUncaught: true,
captureUnhandledRejections: true,
});

const supabaseUrl = process.env.SUPABASE_URL;
const supabaseServiceKey = process.env.SUPABASE_KEY;
const slackChannelID = process.env.SLACK_CHANNEL_HELP_DESIGN_SYSTEM;

const LOG_PREFIX = "[/api/share-search-usage]:";

export default async function handler(req: NextApiRequest, res: NextApiResponse): Promise<void | Response> {
logger.info(`${LOG_PREFIX} Incoming request`);

// if this doesn't get called from a vercel cron job, then it's unauthorized
if (req.headers.authorization !== `Bearer ${process.env.CRON_SECRET}`) {
res.status(401).end("Unauthorized");
return;
}

if (supabaseUrl === undefined) {
logger.error(`${LOG_PREFIX} Supabase URL is undefined`);
rollbar.error(`${LOG_PREFIX} Supabase URL is undefined`);
res.status(500).json({
error: "Supabase URL is undefined",
});
return;
}

if (supabaseServiceKey === undefined) {
logger.error(`${LOG_PREFIX} Supabase service key is undefined`);
rollbar.error(`${LOG_PREFIX} Supabase service key is undefined`);
res.status(500).json({
error: "Supabase service key is undefined",
});
return;
}

if (slackChannelID === undefined) {
logger.error(`${LOG_PREFIX} Slack channel ID is undefined`);
rollbar.error(`${LOG_PREFIX} Slack channel ID is undefined`);
res.status(500).json({
error: "Slack channel ID is undefined",
});
return;
}

const { host } = req.headers;
const protocol = host?.includes("localhost") ? "http" : "https";

const supabaseClient = createClient(supabaseUrl, supabaseServiceKey);

const latestSearchQueriesResponse = await supabaseClient.rpc("match_weekly_search_usage");

if (latestSearchQueriesResponse.error) {
logger.error(`${LOG_PREFIX} Error fetching latest search queries`, {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: I almost wish logger.error also called rollbar.error internally. Would remove a lot of this duplication code

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, it's just a instantiation of a winston logger, we might be able to create a custom transporter that calls rollbar on error maybe? No sure if that's its intended purpose though.

latestSearchQueries: latestSearchQueriesResponse,
});
rollbar.error(`${LOG_PREFIX} Error fetching latest search queries`, {
latestSearchQueries: latestSearchQueriesResponse,
});
res.status(500).json({
error: latestSearchQueriesResponse.error,
});
return;
}

const latestSearchQueries = latestQueriesDataSchema.safeParse(latestSearchQueriesResponse.data);

logger.info(`${LOG_PREFIX} Latest search queries`, { latestSearchQueriesData: latestSearchQueries });

if (!latestSearchQueries.success) {
logger.error(
`${LOG_PREFIX} Invalid db query response structure: ${JSON.stringify(latestSearchQueriesResponse.data)}`,
);
rollbar.error(
`${LOG_PREFIX} Invalid db query response structure: ${JSON.stringify(latestSearchQueriesResponse.data)}`,
);
res.status(400).json({ error: "Invalid db query response structure", details: latestSearchQueries.error });
return;
}

/**
* Take this structured data and turn it into a markdown table
* [
* {
* "query_string": "button",
* "count": 2
* }
* ]
*/

const queries = latestSearchQueries.data.map((query) => query.query_string);
const counts = latestSearchQueries.data.map((query) => query.count.toString());

const maxQueryLength = Math.max(...queries.map((query) => query.length), "Query".length);
const maxCountLength = Math.max(...counts.map((count) => count.length), "Count".length);

let markdownTable = `| ${"Search Term".padEnd(maxQueryLength)} | ${"Count".padEnd(maxCountLength)} |\n| ${"-".repeat(
maxQueryLength,
)} | ${"-".repeat(maxCountLength)} |\n`;

for (const query of latestSearchQueries.data) {
markdownTable += `| ${query.query_string.padEnd(maxQueryLength)} | ${query.count
.toString()
.padEnd(maxCountLength)} |\n`;
}

logger.info(`${LOG_PREFIX} Markdown table`, { markdownTable });

const slackMessage =
`:mag: *Top Search Queries of the Past Week on Paste Docsite* :mag:\n\nThe search feature on the PasteDoc site is a great resource when you're stuck. Check out what your Paste Mates have been searching for most over the past 7 days.\n\n\`\`\`\n${markdownTable}`.concat(
"\n```",
);

try {
const postToSlackResponse = await fetch(`${protocol}://${host}/api/post-to-slack`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
message: slackMessage,
channelID: slackChannelID,
}),
});

logger.info(`${LOG_PREFIX} Posted to Slack`, { postToSlackResponse });
// return the data to the client
res.status(200).json({
latestSearchQueriesData: latestSearchQueries,
});
} catch (error) {
logger.error(`${LOG_PREFIX} Error posting to Slack`, { error });
rollbar.error(`${LOG_PREFIX} Error posting to Slack`, { error });
res.status(500).json({
error: "Error posting to Slack",
details: error,
});
}
}
8 changes: 8 additions & 0 deletions packages/paste-website/vercel.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"crons": [
{
"path": "/api/share-search-usage",
"schedule": "0 10 * * 1"
}
]
}
Loading
Loading