Skip to content

Commit

Permalink
feat: ability to post slack upates (#3744)
Browse files Browse the repository at this point in the history
* feat: ability to post slack upates

* chore: schema types update

* chore: make callable from any environment

* chore: automate it with vercel cron jobs
  • Loading branch information
SiTaggart authored Jan 30, 2024
1 parent fbd3996 commit 9e3cd8f
Show file tree
Hide file tree
Showing 8 changed files with 391 additions and 2 deletions.
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`, {
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

0 comments on commit 9e3cd8f

Please sign in to comment.