diff --git a/apps/backend/README.md b/apps/backend/README.md index 13ef71d609..d8a000fad0 100644 --- a/apps/backend/README.md +++ b/apps/backend/README.md @@ -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 diff --git a/apps/backend/supabase/migrations/20240129215251_top_searches.sql b/apps/backend/supabase/migrations/20240129215251_top_searches.sql new file mode 100644 index 0000000000..bcd6ce9fb5 --- /dev/null +++ b/apps/backend/supabase/migrations/20240129215251_top_searches.sql @@ -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$ +; + + diff --git a/apps/backend/supabase/schema.gen.ts b/apps/backend/supabase/schema.gen.ts index a1837e336d..b1920e810f 100644 --- a/apps/backend/supabase/schema.gen.ts +++ b/apps/backend/supabase/schema.gen.ts @@ -289,6 +289,13 @@ export interface Database { slug: string }[] } + match_weekly_search_usage: { + Args: Record + Returns: { + query_string: string + count: number + }[] + } upsert_story_and_create_story_render: { Args: { _storybook_id: string diff --git a/packages/paste-website/package.json b/packages/paste-website/package.json index d6cd5a4818..6dda75c5d9 100644 --- a/packages/paste-website/package.json +++ b/packages/paste-website/package.json @@ -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", diff --git a/packages/paste-website/src/pages/api/post-to-slack.ts b/packages/paste-website/src/pages/api/post-to-slack.ts new file mode 100644 index 0000000000..10ea5ab849 --- /dev/null +++ b/packages/paste-website/src/pages/api/post-to-slack.ts @@ -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 { + 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, + }); + } +} diff --git a/packages/paste-website/src/pages/api/share-search-usage.ts b/packages/paste-website/src/pages/api/share-search-usage.ts new file mode 100644 index 0000000000..e46a40291b --- /dev/null +++ b/packages/paste-website/src/pages/api/share-search-usage.ts @@ -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 { + 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, + }); + } +} diff --git a/packages/paste-website/vercel.json b/packages/paste-website/vercel.json new file mode 100644 index 0000000000..cba6522709 --- /dev/null +++ b/packages/paste-website/vercel.json @@ -0,0 +1,8 @@ +{ + "crons": [ + { + "path": "/api/share-search-usage", + "schedule": "0 10 * * 1" + } + ] +} diff --git a/yarn.lock b/yarn.lock index 5e8dace5dd..b80bb9bdc6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9111,6 +9111,41 @@ __metadata: languageName: node linkType: hard +"@slack/logger@npm:^4.0.0": + version: 4.0.0 + resolution: "@slack/logger@npm:4.0.0" + dependencies: + "@types/node": ">=18.0.0" + checksum: dc79e9d2032c4bf9ce01d96cc72882f003dd376d036f172d4169662cfc2c9b384a80d5546b06021578dd473e7059f064303f0ba851eeb153387f2081a1e3062e + languageName: node + linkType: hard + +"@slack/types@npm:^2.9.0": + version: 2.11.0 + resolution: "@slack/types@npm:2.11.0" + checksum: b5b7e4be242c9409b247c5be9df480b91a5ad21f367ae96945a7752bd720c65b13623c4a9b37b812107b3a5aa5e5013d7962807230913781ff5f0ad427a79ec2 + languageName: node + linkType: hard + +"@slack/web-api@npm:^7.0.1": + version: 7.0.1 + resolution: "@slack/web-api@npm:7.0.1" + dependencies: + "@slack/logger": ^4.0.0 + "@slack/types": ^2.9.0 + "@types/node": ">=18.0.0" + axios: ^1.6.5 + eventemitter3: ^5.0.1 + form-data: ^4.0.0 + is-electron: 2.2.2 + is-stream: ^2 + p-queue: ^6 + p-retry: ^4 + retry: ^0.13.1 + checksum: 134c0e7416216701b442e74f0057907f78b048e19965f1abd071ad7bc19abb8451b4628899396d20b0348093e8afca2fb234a1708b4da66a132bf069ebe2ffbe + languageName: node + linkType: hard + "@snyk/dep-graph@npm:^2.3.0": version: 2.5.0 resolution: "@snyk/dep-graph@npm:2.5.0" @@ -15670,6 +15705,7 @@ __metadata: "@next/mdx": ^14.0.0 "@octokit/core": ^5.0.1 "@octokit/plugin-paginate-graphql": ^4.0.0 + "@slack/web-api": ^7.0.1 "@storybook/react": 7.6.4 "@supabase/supabase-js": ^2.36.0 "@testing-library/react": ^13.4.0 @@ -16589,6 +16625,15 @@ __metadata: languageName: node linkType: hard +"@types/node@npm:>=18.0.0": + version: 20.11.7 + resolution: "@types/node@npm:20.11.7" + dependencies: + undici-types: ~5.26.4 + checksum: 61ea0718bccda31110c643190518407b7c50d26698a20e3522871608db5fa3d2d43d1ae57c609068eae6996d563db43326045a90f22a9aacc825e8d6c7aea2ce + languageName: node + linkType: hard + "@types/node@npm:>=8.0.0 <15, @types/node@npm:^14.14.31": version: 14.18.36 resolution: "@types/node@npm:14.18.36" @@ -16837,6 +16882,13 @@ __metadata: languageName: node linkType: hard +"@types/retry@npm:0.12.0": + version: 0.12.0 + resolution: "@types/retry@npm:0.12.0" + checksum: 61a072c7639f6e8126588bf1eb1ce8835f2cb9c2aba795c4491cf6310e013267b0c8488039857c261c387e9728c1b43205099223f160bb6a76b4374f741b5603 + languageName: node + linkType: hard + "@types/retry@npm:^0.12.0": version: 0.12.1 resolution: "@types/retry@npm:0.12.1" @@ -19213,6 +19265,17 @@ __metadata: languageName: node linkType: hard +"axios@npm:^1.6.5": + version: 1.6.7 + resolution: "axios@npm:1.6.7" + dependencies: + follow-redirects: ^1.15.4 + form-data: ^4.0.0 + proxy-from-env: ^1.1.0 + checksum: 87d4d429927d09942771f3b3a6c13580c183e31d7be0ee12f09be6d5655304996bb033d85e54be81606f4e89684df43be7bf52d14becb73a12727bf33298a082 + languageName: node + linkType: hard + "axobject-query@npm:^2.2.0": version: 2.2.0 resolution: "axobject-query@npm:2.2.0" @@ -25097,6 +25160,13 @@ __metadata: languageName: node linkType: hard +"eventemitter3@npm:^5.0.1": + version: 5.0.1 + resolution: "eventemitter3@npm:5.0.1" + checksum: 543d6c858ab699303c3c32e0f0f47fc64d360bf73c3daf0ac0b5079710e340d6fe9f15487f94e66c629f5f82cd1a8678d692f3dbb6f6fcd1190e1b97fcad36f8 + languageName: node + linkType: hard + "events@npm:^3.2.0": version: 3.3.0 resolution: "events@npm:3.3.0" @@ -26090,6 +26160,16 @@ __metadata: languageName: node linkType: hard +"follow-redirects@npm:^1.15.4": + version: 1.15.5 + resolution: "follow-redirects@npm:1.15.5" + peerDependenciesMeta: + debug: + optional: true + checksum: 5ca49b5ce6f44338cbfc3546823357e7a70813cecc9b7b768158a1d32c1e62e7407c944402a918ea8c38ae2e78266312d617dc68783fac502cbb55e1047b34ec + languageName: node + linkType: hard + "for-each@npm:^0.3.3": version: 0.3.3 resolution: "for-each@npm:0.3.3" @@ -29134,6 +29214,13 @@ fsevents@^1.2.7: languageName: node linkType: hard +"is-electron@npm:2.2.2": + version: 2.2.2 + resolution: "is-electron@npm:2.2.2" + checksum: de5aa8bd8d72c96675b8d0f93fab4cc21f62be5440f65bc05c61338ca27bd851a64200f31f1bf9facbaa01b3dbfed7997b2186741d84b93b63e0aff1db6a9494 + languageName: node + linkType: hard + "is-even@npm:^1.0.0": version: 1.0.0 resolution: "is-even@npm:1.0.0" @@ -29564,6 +29651,13 @@ fsevents@^1.2.7: languageName: node linkType: hard +"is-stream@npm:^2": + version: 2.0.1 + resolution: "is-stream@npm:2.0.1" + checksum: b8e05ccdf96ac330ea83c12450304d4a591f9958c11fd17bed240af8d5ffe08aedafa4c0f4cfccd4d28dc9d4d129daca1023633d5c11601a6cbc77521f6fae66 + languageName: node + linkType: hard + "is-string@npm:^1.0.5, is-string@npm:^1.0.7": version: 1.0.7 resolution: "is-string@npm:1.0.7" @@ -36249,7 +36343,7 @@ fsevents@^1.2.7: languageName: node linkType: hard -"p-queue@npm:6.6.2": +"p-queue@npm:6.6.2, p-queue@npm:^6": version: 6.6.2 resolution: "p-queue@npm:6.6.2" dependencies: @@ -36266,6 +36360,16 @@ fsevents@^1.2.7: languageName: node linkType: hard +"p-retry@npm:^4": + version: 4.6.2 + resolution: "p-retry@npm:4.6.2" + dependencies: + "@types/retry": 0.12.0 + retry: ^0.13.1 + checksum: 45c270bfddaffb4a895cea16cb760dcc72bdecb6cb45fef1971fa6ea2e91ddeafddefe01e444ac73e33b1b3d5d29fb0dd18a7effb294262437221ddc03ce0f2e + languageName: node + linkType: hard + "p-retry@npm:^4.5.0": version: 4.6.1 resolution: "p-retry@npm:4.6.1"