diff --git a/apps/nextjs/src/app/api/cron-jobs/google-drive-size-quota/route.ts b/apps/nextjs/src/app/api/cron-jobs/google-drive-size-quota/route.ts new file mode 100644 index 000000000..8590fba0a --- /dev/null +++ b/apps/nextjs/src/app/api/cron-jobs/google-drive-size-quota/route.ts @@ -0,0 +1,114 @@ +import { + slackAiOpsNotificationChannelId, + slackWebClient, +} from "@oakai/core/src/utils/slack"; +import { googleDrive } from "@oakai/exports/src/gSuite/drive/client"; +import { aiLogger } from "@oakai/logger"; +import * as Sentry from "@sentry/node"; +import type { NextRequest } from "next/server"; + +const log = aiLogger("cron"); + +const requiredEnvVars = ["CRON_SECRET", "SLACK_AI_OPS_NOTIFICATION_CHANNEL_ID"]; + +requiredEnvVars.forEach((envVar) => { + if (!process.env[envVar]) { + throw new Error(`Environment variable ${envVar} is not set.`); + } +}); + +async function fetchDriveUsage() { + try { + const res = await googleDrive.about.get({ + fields: "storageQuota, user(emailAddress)", + }); + + const storageQuota = res.data.storageQuota; + const userEmail = res.data.user?.emailAddress; + + if (!storageQuota) { + throw new Error("Unable to fetch storage quota information."); + } + + const usage = { + limit: parseInt(storageQuota.limit ?? "0", 10), + usage: parseInt(storageQuota.usage ?? "0", 10), + userEmail, + }; + + log.info( + `Drive usage retrieved: ${usage.usage} bytes used of ${usage.limit} bytes total, ${userEmail}.`, + ); + + return usage; + } catch (error) { + log.error("Failed to fetch Google Drive usage details:", error); + throw error; + } +} + +async function checkDriveUsageThreshold(thresholdPercentage: number = 80) { + try { + const usage = await fetchDriveUsage(); + + if (usage.limit === 0) { + throw new Error("Storage limit is reported as zero, which is invalid."); + } + + const usagePercentage = (usage.usage / usage.limit) * 100; + + log.info( + `Drive usage percentage: ${usagePercentage.toFixed( + 2, + )}%. Threshold is set at ${thresholdPercentage}%.`, + ); + + if (usagePercentage > thresholdPercentage) { + const errorMessage = `Drive usage is at ${usagePercentage.toFixed( + 2, + )}% of the total limit, exceeding the threshold of ${thresholdPercentage}% : ${usage.userEmail}`; + log.error(errorMessage); + Sentry.captureMessage(errorMessage); + await slackWebClient.chat.postMessage({ + channel: slackAiOpsNotificationChannelId, + text: errorMessage, + }); + } + } catch (error) { + log.error("Error during Drive usage check:", error); + Sentry.captureException(error); + throw error; + } +} + +export async function GET(request: NextRequest) { + try { + const authHeader = request.headers.get("authorization"); + const cronSecret = process.env.CRON_SECRET; + + if (!cronSecret) { + log.error("Missing cron secret"); + return new Response("Missing cron secret", { status: 500 }); + } + + if (authHeader !== `Bearer ${cronSecret}`) { + log.error("Authorization failed. Invalid token."); + return new Response("Unauthorized", { status: 401 }); + } + + log.info("Starting Google Drive usage check..."); + + await checkDriveUsageThreshold(80); + + return new Response("Drive usage check completed successfully.", { + status: 200, + }); + } catch (error) { + log.error( + "An error occurred during the Drive usage check cron job:", + error, + ); + Sentry.captureException(error); + return new Response("Internal Server Error", { status: 500 }); + } +} diff --git a/apps/nextjs/src/middlewares/auth.middleware.ts b/apps/nextjs/src/middlewares/auth.middleware.ts index 086bbd871..23e5feb18 100644 --- a/apps/nextjs/src/middlewares/auth.middleware.ts +++ b/apps/nextjs/src/middlewares/auth.middleware.ts @@ -25,6 +25,7 @@ const publicRoutes = [ "/api/trpc/main/health.prismaCheck", "/api/trpc/chat/chat.health.check", "/api/cron-jobs/expired-exports", + "/api/cron-jobs/google-drive-size-quota", /** * The inngest route is protected using a signing key * @see https://www.inngest.com/docs/faq#my-app-s-serve-endpoint-requires-authentication-what-should-i-do diff --git a/apps/nextjs/vercel.json b/apps/nextjs/vercel.json index 8746c630a..891e67fdf 100644 --- a/apps/nextjs/vercel.json +++ b/apps/nextjs/vercel.json @@ -13,6 +13,10 @@ { "path": "/api/cron-jobs/expired-exports", "schedule": "0 3 * * *" + }, + { + "path": "/api/cron-jobs/google-drive-size-quota", + "schedule": "0 4 * * *" } ] } diff --git a/packages/core/src/utils/slack.ts b/packages/core/src/utils/slack.ts index c489e0fbf..1fc6b088c 100644 --- a/packages/core/src/utils/slack.ts +++ b/packages/core/src/utils/slack.ts @@ -1,4 +1,4 @@ -import type { ActionsBlock, SectionBlock} from "@slack/web-api"; +import type { ActionsBlock, SectionBlock } from "@slack/web-api"; import { WebClient } from "@slack/web-api"; import { uniqueNamesGenerator, @@ -9,12 +9,18 @@ import { import { getExternalFacingUrl } from "../functions/slack/getExternalFacingUrl"; -if (!process.env.SLACK_NOTIFICATION_CHANNEL_ID) { +if ( + !process.env.SLACK_NOTIFICATION_CHANNEL_ID || + !process.env.SLACK_AI_OPS_NOTIFICATION_CHANNEL_ID +) { throw new Error("Missing env var SLACK_NOTIFICATION_CHANNEL_ID"); } export const slackNotificationChannelId = process.env.SLACK_NOTIFICATION_CHANNEL_ID; +export const slackAiOpsNotificationChannelId = + process.env.SLACK_AI_OPS_NOTIFICATION_CHANNEL_ID; + export const slackWebClient = new WebClient( process.env.SLACK_BOT_USER_OAUTH_TOKEN, );