diff --git a/README.md b/README.md index 0b36461..df6ebc1 100644 --- a/README.md +++ b/README.md @@ -75,8 +75,26 @@ S3_PORT= S3_ACCESS_KEY_ID= S3_SECRET_ACCESS_KEY= S3_USE_SSL= + +DISCORD_APPLICATION_ID= +DISCORD_PUBLIC_KEY= +DISCORD_GUILD_ID= +DISCORD_BOT_TOKEN= +DISCORD_REVIEW_FORUM_CHANNEL_ID= +DISCORD_REPLY_ROLE_IDS= +DISCORD_FORUM_TAG_PENDING_ID= +DISCORD_FORUM_TAG_APPROVED_ID= +DISCORD_FORUM_TAG_CLAIMED_ID= +DISCORD_FORUM_TAG_UNCLAIMED_ID= + +RESEND_API_KEY= +RESEND_WEBHOOK_SECRET= +RESEND_INBOUND_DOMAIN= +RESEND_FROM= ``` +Register Discord guild commands locally with `npm run discord:register`; in production, an admin can visit `/api/discord/register`. + ### Supabase (local) Follow the official guide to run Supabase locally with the CLI (includes Studio): diff --git a/package-lock.json b/package-lock.json index 9881e26..3022fb0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "@vercel/edge-config": "^1.4.3", "canvas-confetti": "^1.9.4", "chart.js": "^4.5.1", + "discord-interactions": "^4.4.0", "embla-carousel-react": "8.6.0", "js-sha1": "^0.7.0", "mdast-util-to-hast": "^13.2.1", @@ -36,6 +37,7 @@ "rehype-slug": "^6.0.0", "remark-custom-header-id": "^1.0.0", "remark-gfm": "4.0.0", + "resend": "^6.22.0", "rom-patcher-js": "github:Hackdex-App/RomPatcher.js", "schema-dts": "^1.1.5", "serialize-javascript": "^7.0.0", @@ -2375,6 +2377,12 @@ "node": ">=18.0.0" } }, + "node_modules/@stablelib/base64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", + "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", + "license": "MIT" + }, "node_modules/@supabase/auth-js": { "version": "2.74.0", "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.74.0.tgz", @@ -4236,6 +4244,15 @@ "scripts/actions/documentation" ] }, + "node_modules/discord-interactions": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/discord-interactions/-/discord-interactions-4.4.0.tgz", + "integrity": "sha512-jjJx8iwAeJcj8oEauV43fue9lNqkf38fy60aSs2+G8D1nJmDxUIrk08o3h0F3wgwuBWWJUZO+X/VgfXsxpCiJA==", + "license": "MIT", + "engines": { + "node": ">=18.4.0" + } + }, "node_modules/dom-serializer": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", @@ -4644,6 +4661,12 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-sha256": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", + "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", + "license": "Unlicense" + }, "node_modules/fast-uri": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", @@ -7891,6 +7914,12 @@ "node": ">= 0.4" } }, + "node_modules/postal-mime": { + "version": "2.7.5", + "resolved": "https://registry.npmjs.org/postal-mime/-/postal-mime-2.7.5.tgz", + "integrity": "sha512-GNEXKvWFQnbgO5NlrGzVa0FmWzBZ24PersAWErttSg1Hjpf0ATxTwS5DOMGaOpTG6bUh5cTr7xi0jAD942wCJA==", + "license": "MIT-0" + }, "node_modules/postcss": { "version": "8.5.15", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", @@ -8826,6 +8855,27 @@ "node": ">=0.10.0" } }, + "node_modules/resend": { + "version": "6.22.0", + "resolved": "https://registry.npmjs.org/resend/-/resend-6.22.0.tgz", + "integrity": "sha512-dP1jyl0n1Dx7GYDb0bcsbv9Yx0oBRLhUtSyNFRIOsSzV9MvCVbua/3WP6YUP2JptAU5BciEWGInV+hFjUuYLUw==", + "license": "MIT", + "dependencies": { + "postal-mime": "2.7.5", + "standardwebhooks": "1.0.0" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@react-email/render": "*" + }, + "peerDependenciesMeta": { + "@react-email/render": { + "optional": true + } + } + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -9125,6 +9175,16 @@ "node": ">=6" } }, + "node_modules/standardwebhooks": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz", + "integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==", + "license": "MIT", + "dependencies": { + "@stablelib/base64": "^1.0.0", + "fast-sha256": "^1.3.0" + } + }, "node_modules/stream-chain": { "version": "2.2.5", "resolved": "https://registry.npmjs.org/stream-chain/-/stream-chain-2.2.5.tgz", diff --git a/package.json b/package.json index 7d6ebc1..312c1a5 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "build": "next build --turbopack", "start": "next start", "db:pull": "supabase db pull && supabase migration up && supabase gen types --local > src/types/db.ts", + "discord:register": "node scripts/register-discord-commands.mjs", "postinstall": "patch-package" }, "dependencies": { @@ -21,6 +22,7 @@ "@vercel/edge-config": "^1.4.3", "canvas-confetti": "^1.9.4", "chart.js": "^4.5.1", + "discord-interactions": "^4.4.0", "embla-carousel-react": "8.6.0", "js-sha1": "^0.7.0", "mdast-util-to-hast": "^13.2.1", @@ -37,6 +39,7 @@ "rehype-slug": "^6.0.0", "remark-custom-header-id": "^1.0.0", "remark-gfm": "4.0.0", + "resend": "^6.22.0", "rom-patcher-js": "github:Hackdex-App/RomPatcher.js", "schema-dts": "^1.1.5", "serialize-javascript": "^7.0.0", diff --git a/scripts/register-discord-commands.mjs b/scripts/register-discord-commands.mjs new file mode 100644 index 0000000..cd8d31f --- /dev/null +++ b/scripts/register-discord-commands.mjs @@ -0,0 +1,33 @@ +import { discordGuildCommands } from "../src/utils/discord-commands.mjs"; + +const requiredEnvironment = [ + "DISCORD_APPLICATION_ID", + "DISCORD_GUILD_ID", + "DISCORD_BOT_TOKEN", +]; + +const missingEnvironment = requiredEnvironment.filter((name) => !process.env[name]); +if (missingEnvironment.length > 0) { + console.error(`Missing Discord environment: ${missingEnvironment.join(", ")}`); + process.exit(1); +} + +const response = await fetch( + `https://discord.com/api/v10/applications/${process.env.DISCORD_APPLICATION_ID}/guilds/${process.env.DISCORD_GUILD_ID}/commands`, + { + method: "PUT", + headers: { + Authorization: `Bot ${process.env.DISCORD_BOT_TOKEN}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(discordGuildCommands), + }, +); + +if (!response.ok) { + console.error(`Discord command registration failed (${response.status}): ${await response.text()}`); + process.exit(1); +} + +const registered = await response.json(); +console.log(`Registered ${registered.length} guild command(s).`); diff --git a/src/app/api/discord/interactions/route.ts b/src/app/api/discord/interactions/route.ts new file mode 100644 index 0000000..e2a5d27 --- /dev/null +++ b/src/app/api/discord/interactions/route.ts @@ -0,0 +1,223 @@ +import { + InteractionResponseFlags, + InteractionResponseType, + InteractionType, +} from "discord-interactions"; +import { after } from "next/server"; + +import { + getDiscordThread, + verifyDiscordRequest, +} from "@/utils/discord-rest"; +import { + emailHackCreator, + postHackReviewMessage, +} from "@/utils/hack-review"; +import { createServiceClient } from "@/utils/supabase/server"; + +type DiscordInteraction = { + application_id: string; + token: string; + type: number; + channel_id?: string; + data?: { + name?: string; + type?: number; + options?: Array<{ name: string; value?: string }>; + }; + member?: { + nick?: string | null; + roles?: string[]; + user?: { + id?: string; + avatar?: string | null; + global_name?: string | null; + username?: string; + }; + }; +}; + +function ephemeral(content: string): Response { + return Response.json({ + type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, + data: { + content, + flags: InteractionResponseFlags.EPHEMERAL, + }, + }); +} + +async function editDeferredResponse( + interaction: DiscordInteraction, + content: string, +): Promise { + const response = await fetch( + `https://discord.com/api/v10/webhooks/${interaction.application_id}/${interaction.token}/messages/@original`, + { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ content }), + }, + ); + if (!response.ok) { + throw new Error(`Discord interaction response failed: ${response.status} ${await response.text()}`); + } +} + +export async function POST(request: Request) { + const rawBody = await request.text(); + const isValid = await verifyDiscordRequest( + rawBody, + request.headers.get("x-signature-ed25519"), + request.headers.get("x-signature-timestamp"), + ); + if (!isValid) { + return new Response("Invalid request signature", { status: 401 }); + } + + let interaction: DiscordInteraction; + try { + interaction = JSON.parse(rawBody) as DiscordInteraction; + } catch { + return new Response("Invalid JSON", { status: 400 }); + } + + if (interaction.type === InteractionType.PING) { + return Response.json({ type: InteractionResponseType.PONG }); + } + + if ( + interaction.type !== InteractionType.APPLICATION_COMMAND + || interaction.data?.name !== "reply" + || interaction.data.type !== 1 + ) { + return ephemeral("Unsupported command."); + } + + const message = interaction.data.options + ?.find((option) => option.name === "message") + ?.value + ?.trim(); + if (!message || !interaction.channel_id) { + return ephemeral("A message is required."); + } + if (message.length > 1800) { + return ephemeral("The message must be 1,800 characters or fewer."); + } + + const deferredResponse = Response.json({ + type: InteractionResponseType.DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE, + data: { flags: InteractionResponseFlags.EPHEMERAL }, + }); + + after(async () => { + let emailSentConfirmation: string | null = null; + let threadPostSucceeded = false; + try { + const serviceClient = await createServiceClient(); + const { data: reviewThread, error } = await serviceClient + .from("hack_review_threads") + .select("*") + .eq("discord_thread_id", interaction.channel_id!) + .maybeSingle(); + if (error) throw error; + + const discordThread = reviewThread + ? await getDiscordThread(interaction.channel_id!) + : null; + if ( + !reviewThread + || !discordThread + || discordThread.parent_id !== reviewThread.discord_parent_channel_id + ) { + await editDeferredResponse( + interaction, + "This command can only be used in a mapped Hackdex review thread.", + ); + return; + } + + const replyRoleIds = (process.env.DISCORD_REPLY_ROLE_IDS ?? "") + .split(",") + .map((roleId) => roleId.trim()) + .filter(Boolean); + if (replyRoleIds.length === 0) { + console.error( + "[HackReview] DISCORD_REPLY_ROLE_IDS is missing or empty; refusing /reply.", + ); + await editDeferredResponse( + interaction, + "This command is not configured.", + ); + return; + } + const memberRoles = interaction.member?.roles ?? []; + if (!replyRoleIds.some((roleId) => memberRoles.includes(roleId))) { + await editDeferredResponse( + interaction, + "You do not have permission to use this command.", + ); + return; + } + + const adminName = interaction.member?.nick + || interaction.member?.user?.global_name + || interaction.member?.user?.username + || "Hackdex admin"; + const discordUser = interaction.member?.user; + const avatarUrl = discordUser?.id && discordUser.avatar + ? `https://cdn.discordapp.com/avatars/${discordUser.id}/${discordUser.avatar}.png` + : undefined; + const emailResult = await emailHackCreator({ + hackSlug: reviewThread.hack_slug, + message, + adminName, + }); + if (!emailResult.ok) { + await editDeferredResponse(interaction, emailResult.error); + return; + } + emailSentConfirmation = `emailed ${emailResult.email} as ${adminName}`; + + const threadPostResult = await postHackReviewMessage(reviewThread, { + embeds: [{ + title: emailResult.subject, + author: { + name: adminName, + ...(avatarUrl ? { icon_url: avatarUrl } : {}), + }, + description: message, + footer: { + text: emailResult.creatorUsername + ? `Sent to the email of ${emailResult.creatorUsername}` + : "Sent to the email of the hack creator", + }, + color: 0x57f287, + }], + }); + threadPostSucceeded = threadPostResult === "posted"; + await editDeferredResponse( + interaction, + threadPostSucceeded + ? emailSentConfirmation + : `${emailSentConfirmation}. The review thread message could not be posted.`, + ); + } catch (error) { + console.error("[HackReview] Failed to handle /reply:", error); + try { + await editDeferredResponse( + interaction, + emailSentConfirmation + ? threadPostSucceeded + ? emailSentConfirmation + : `${emailSentConfirmation}. The review thread message could not be posted.` + : "The review email could not be sent.", + ); + } catch (responseError) { + console.error("[HackReview] Failed to update the deferred interaction:", responseError); + } + } + }); + + return deferredResponse; +} diff --git a/src/app/api/discord/register/route.ts b/src/app/api/discord/register/route.ts new file mode 100644 index 0000000..ce2df16 --- /dev/null +++ b/src/app/api/discord/register/route.ts @@ -0,0 +1,36 @@ +import { NextResponse } from "next/server"; +import { + DiscordEnvironmentError, + registerDiscordGuildCommands, +} from "@/utils/discord-rest"; +import { createClient } from "@/utils/supabase/server"; +import { checkUserRoles } from "@/utils/user"; + +export async function GET() { + const supa = await createClient(); + const { data: { user } } = await supa.auth.getUser(); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + const { isAdmin } = await checkUserRoles(supa); + if (!isAdmin) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + try { + const registered = await registerDiscordGuildCommands(); + return NextResponse.json({ + count: registered.length, + names: registered.map((command) => command.name), + }); + } catch (error) { + if (error instanceof DiscordEnvironmentError) { + return NextResponse.json({ error: error.message }, { status: 500 }); + } + + const message = error instanceof Error + ? error.message + : "Discord command registration failed"; + return NextResponse.json({ error: message }, { status: 502 }); + } +} diff --git a/src/app/api/resend/inbound/route.ts b/src/app/api/resend/inbound/route.ts new file mode 100644 index 0000000..6e353f8 --- /dev/null +++ b/src/app/api/resend/inbound/route.ts @@ -0,0 +1,242 @@ +import type { APIEmbed } from "discord-api-types/v10"; +import { Resend, type EmailReceivedEvent } from "resend"; + +import { sendDiscordMessageEmbed } from "@/utils/discord"; +import { postHackReviewMessage } from "@/utils/hack-review"; +import { createServiceClient } from "@/utils/supabase/server"; + +const processedWebhookIds = new Set(); +const MAX_DEDUPE_IDS = 500; + +function rememberWebhookId(id: string): void { + processedWebhookIds.add(id); + if (processedWebhookIds.size > MAX_DEDUPE_IDS) { + const oldest = processedWebhookIds.values().next().value; + if (oldest) processedWebhookIds.delete(oldest); + } +} + +function stripQuotedReply(text: string): string { + const withoutHistory = text.split( + /\n(?:On .+wrote:|From:\s.+|[-_]{2,}\s*Original Message\s*[-_]{2,})/i, + 1, + )[0]; + return withoutHistory + .split(/\r?\n/) + .filter((line) => !line.trimStart().startsWith(">")) + .join("\n") + .trim(); +} + +function headerValue( + headers: Record | null, + name: string, +): string | undefined { + const entry = Object.entries(headers ?? {}).find( + ([key]) => key.toLowerCase() === name.toLowerCase(), + ); + return entry?.[1]; +} + +function normalizeEmailAddress(value: string): string { + const bracketedAddress = value.match(/<\s*([^<>]+)\s*>/)?.[1]; + return (bracketedAddress ?? value).trim().toLowerCase(); +} + +function replyTokenFromAddresses( + addresses: string[], + inboundDomain: string, +): string | null { + const escapedDomain = inboundDomain.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const pattern = new RegExp(`reviews\\+([A-Za-z0-9_-]+)@${escapedDomain}`, "i"); + for (const address of addresses) { + const match = address.match(pattern); + if (match) return match[1].toLowerCase(); + } + return null; +} + +export async function POST(request: Request) { + const apiKey = process.env.RESEND_API_KEY; + const webhookSecret = process.env.RESEND_WEBHOOK_SECRET; + const inboundDomain = process.env.RESEND_INBOUND_DOMAIN; + if (!apiKey || !webhookSecret || !inboundDomain) { + console.warn( + "[HackReview] RESEND_API_KEY, RESEND_WEBHOOK_SECRET, or RESEND_INBOUND_DOMAIN is missing; skipping inbound email.", + ); + return new Response("Resend inbound email is not configured", { status: 202 }); + } + + const rawBody = await request.text(); + const webhookId = request.headers.get("svix-id"); + const timestamp = request.headers.get("svix-timestamp"); + const signature = request.headers.get("svix-signature"); + if (!webhookId || !timestamp || !signature) { + return new Response("Missing webhook signature", { status: 401 }); + } + if (processedWebhookIds.has(webhookId)) { + return new Response("Already processed", { status: 200 }); + } + + const resend = new Resend(apiKey); + let event: EmailReceivedEvent; + try { + const verified = resend.webhooks.verify({ + payload: rawBody, + headers: { id: webhookId, timestamp, signature }, + webhookSecret, + }); + if (verified.type !== "email.received") { + return new Response("Ignored", { status: 200 }); + } + event = verified; + } catch (error) { + console.warn("[HackReview] Invalid Resend webhook signature:", error); + return new Response("Invalid webhook signature", { status: 401 }); + } + + rememberWebhookId(webhookId); + try { + const { data: email, error: emailError } = + await resend.emails.receiving.get(event.data.email_id); + if (emailError || !email) { + throw new Error(emailError?.message ?? "Inbound email was not found"); + } + + const serviceClient = await createServiceClient(); + const addresses = [ + ...event.data.to, + ...event.data.received_for, + ...email.to, + ...email.received_for, + ]; + const replyToken = replyTokenFromAddresses(addresses, inboundDomain); + let reviewThread = null; + + if (replyToken) { + const escapedReplyToken = replyToken.replaceAll("_", "\\_"); + const { data, error } = await serviceClient + .from("hack_review_threads") + .select("*") + .ilike("reply_token", escapedReplyToken) + .maybeSingle(); + if (error) throw error; + reviewThread = data; + } + + if (!reviewThread) { + const referenceHeaders = [ + headerValue(email.headers, "in-reply-to"), + headerValue(email.headers, "references"), + ].filter((value): value is string => Boolean(value)); + const messageIds = Array.from(new Set( + referenceHeaders.flatMap((value) => { + const bracketedIds = value.match(/<[^>]+>/g) ?? []; + return [ + value.trim(), + ...bracketedIds, + ...bracketedIds.map((id) => id.slice(1, -1)), + ...value.split(/\s+/).filter(Boolean), + ]; + }), + )); + if (messageIds.length > 0) { + const { data, error } = await serviceClient + .from("hack_review_threads") + .select("*") + .in("resend_last_message_id", messageIds) + .limit(1) + .maybeSingle(); + if (error) throw error; + reviewThread = data; + } + } + + if (reviewThread?.resend_last_email_id === email.id) { + return new Response("Already processed", { status: 200 }); + } + + const body = stripQuotedReply(email.text ?? ""); + let creatorEmailMatches = false; + if (reviewThread) { + const { data: hack, error: hackError } = await serviceClient + .from("hacks") + .select("created_by") + .eq("slug", reviewThread.hack_slug) + .maybeSingle(); + if (hackError || !hack) { + console.warn( + "[HackReview] Could not load the hack creator for inbound email verification:", + hackError ?? "Hack not found", + ); + } else { + const { data: creatorData, error: creatorError } = + await serviceClient.auth.admin.getUserById(hack.created_by); + const creatorEmail = creatorData?.user?.email; + if (creatorError || !creatorEmail) { + console.warn( + "[HackReview] Could not load the hack creator email for inbound verification:", + creatorError ?? "No email found", + ); + } else { + creatorEmailMatches = + normalizeEmailAddress(email.from) === normalizeEmailAddress(creatorEmail); + } + } + } + + const embed: APIEmbed = { + title: (email.subject || "(No subject)").slice(0, 256), + description: (body || "(No plain-text body)").slice(0, 3500), + color: 0x5865f2, + fields: [ + { + name: reviewThread + ? `From ${creatorEmailMatches ? "✅" : "❓"}` + : "From", + value: email.from.slice(0, 1024), + inline: true, + }, + ...(!reviewThread + ? [{ + name: "To", + value: (email.to.join(", ") || inboundDomain).slice(0, 1024), + inline: true, + }] + : []), + ], + }; + + if (reviewThread) { + const postResult = await postHackReviewMessage(reviewThread, { + embeds: [embed], + }); + if (postResult !== "posted") { + throw new Error("Inbound email could not be posted to Discord"); + } + const { error: updateError } = await serviceClient + .from("hack_review_threads") + .update({ + resend_last_email_id: email.id, + resend_last_message_id: email.message_id, + }) + .eq("hack_slug", reviewThread.hack_slug); + if (updateError) { + console.error("[HackReview] Failed to persist inbound email metadata:", updateError); + } + } else if (process.env.DISCORD_WEBHOOK_ADMIN_HACKS_URL) { + await sendDiscordMessageEmbed( + process.env.DISCORD_WEBHOOK_ADMIN_HACKS_URL, + [embed], + ); + } else { + console.warn("[HackReview] Inbound email was unmatched and no admin webhook is configured."); + } + + return new Response("Processed", { status: 200 }); + } catch (error) { + processedWebhookIds.delete(webhookId); + console.error("[HackReview] Failed to process inbound email:", error); + return new Response("Failed to process inbound email", { status: 500 }); + } +} diff --git a/src/app/dashboard/actions.ts b/src/app/dashboard/actions.ts index a416cbc..4fc1f67 100644 --- a/src/app/dashboard/actions.ts +++ b/src/app/dashboard/actions.ts @@ -3,6 +3,8 @@ import { unstable_cache as cache } from "next/cache"; import { createClient, createServiceClient } from "@/utils/supabase/server"; import { canEditAsCreator, canEditAsArchiver } from "@/utils/hack"; +import { getHackReviewThread, postHackReviewMessage } from "@/utils/hack-review"; +import { claimDiscordReviewThread } from "@/utils/discord-rest"; import { checkUserRoles } from "@/utils/user"; interface SeriesDataset { @@ -324,4 +326,33 @@ export const assignHacksToAdminForReview = async ({ slugs }: { slugs: string[] } .update({ assigned_admin: user.id }) .in("slug", slugs); if (error) throw error; + + const { data: profile, error: profileError } = await supa + .from("profiles") + .select("username") + .eq("id", user.id) + .maybeSingle(); + if (profileError) { + console.warn("[HackReview] Failed to load claiming admin profile:", profileError); + } + const adminName = profile?.username || "An admin"; + + await Promise.all( + slugs.map(async (slug) => { + try { + const reviewThread = await getHackReviewThread(slug); + if (!reviewThread) return; + + await postHackReviewMessage(reviewThread, { + content: `${adminName} has claimed the hack for review.`, + }); + await claimDiscordReviewThread(reviewThread.discord_thread_id); + } catch (notificationError) { + console.error( + `[HackReview] Failed to notify the review thread for ${slug}:`, + notificationError, + ); + } + }), + ); }; diff --git a/src/app/hack/[slug]/page.tsx b/src/app/hack/[slug]/page.tsx index 011b5fd..f0f4996 100644 --- a/src/app/hack/[slug]/page.tsx +++ b/src/app/hack/[slug]/page.tsx @@ -25,6 +25,8 @@ import Avatar from "@/components/Account/Avatar"; import CollapsibleCard from "@/components/Primitives/CollapsibleCard"; import CollapsibleTags from "@/components/Hack/CollapsibleTags"; import { getHackMetadata, getHackDownloads } from "@/app/hack/[slug]/actions"; +import CreateReviewThreadMenuItem from "@/components/Hack/CreateReviewThreadMenuItem"; +import { getHackReviewThread } from "@/utils/hack-review"; interface HackDetailProps { params: Promise<{ slug: string }>; @@ -146,6 +148,11 @@ export default async function HackDetail({ params }: HackDetailProps) { } } + const hasReviewThread = + isAdmin && !isArchive + ? Boolean(await getHackReviewThread(hack.slug)) + : false; + // Extract patch info from cached metadata const patchFilename = patch?.filename || null; const patchVersion = displayVersion; @@ -460,6 +467,9 @@ export default async function HackDetail({ params }: HackDetailProps) { Contact creator )} + {isAdmin && !isArchive && !hasReviewThread && ( + + )} diff --git a/src/app/hack/actions.ts b/src/app/hack/actions.ts index b4c8cf6..c34dfb0 100644 --- a/src/app/hack/actions.ts +++ b/src/app/hack/actions.ts @@ -11,6 +11,12 @@ import { checkEditPermission, checkPatchEditPermission } from "@/utils/hack"; import { getCachedTagsWithUsage, resolveTagIdsInOrder } from "@/data/tags"; import { sendTransactionalEmail } from "@/utils/email"; import { renderEmail } from "@/emails/render"; +import { approveDiscordReviewThread } from "@/utils/discord-rest"; +import { + ensureHackReviewThread, + getHackReviewThread, + postHackReviewMessage, +} from "@/utils/hack-review"; export async function updateHack(args: { slug: string; @@ -354,6 +360,18 @@ export async function approveHack(slug: string, verified?: boolean) { console.error("[HackApprove] Failed to send email to creator:", error); } + try { + const reviewThread = await getHackReviewThread(slug); + if (reviewThread) { + await postHackReviewMessage(reviewThread, { + content: `✅ **${hack.title}** has been approved and is now live on Hackdex.`, + }); + await approveDiscordReviewThread(reviewThread.discord_thread_id); + } + } catch (error) { + console.error(`[HackReview] Failed to update the approved review thread for ${slug}:`, error); + } + if (process.env.DISCORD_WEBHOOK_HACKDEX_HACKS_URL) { const { data: profile } = await serviceClient.from('profiles').select('*').eq('id', hack.created_by).single(); const displayName = profile?.username ? `@${profile.username}` : user.id; @@ -377,3 +395,73 @@ export async function approveHack(slug: string, verified?: boolean) { redirect(`/hack/${slug}`); } +export async function createHackReviewThread(slug: string) { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user) return { ok: false, error: "Unauthorized" } as const; + + const { data: isAdmin } = await supabase.rpc("is_admin"); + if (!isAdmin) return { ok: false, error: "Forbidden" } as const; + + const serviceClient = await createServiceClient(); + const { data: hack, error: hackError } = await serviceClient + .from("hacks") + .select("slug, title, created_by, assigned_admin, is_archive") + .eq("slug", slug) + .maybeSingle(); + if (hackError) return { ok: false, error: hackError.message } as const; + if (!hack) return { ok: false, error: "Hack not found" } as const; + if (hack.is_archive) { + return { ok: false, error: "Archive hacks cannot have review threads." } as const; + } + + try { + const existingThread = await getHackReviewThread(slug); + if (existingThread) { + revalidatePath(`/hack/${slug}`); + return { ok: true, alreadyExists: true } as const; + } + + const { data: profile } = await serviceClient + .from("profiles") + .select("username") + .eq("id", hack.created_by) + .maybeSingle(); + const { data: assignedProfile } = hack.assigned_admin + ? await serviceClient + .from("profiles") + .select("username") + .eq("id", hack.assigned_admin) + .maybeSingle() + : { data: null }; + const reviewThread = await ensureHackReviewThread({ + slug: hack.slug, + title: hack.title, + author: profile?.username ? `@${profile.username}` : hack.created_by, + isClaimed: hack.assigned_admin !== null, + }); + if (!reviewThread) { + return { + ok: false, + error: "Failed to create the Discord review thread.", + } as const; + } + if (hack.assigned_admin) { + await postHackReviewMessage(reviewThread, { + content: `${assignedProfile?.username || "An admin"} has claimed the hack for review.`, + }); + } + + revalidatePath(`/hack/${slug}`); + return { ok: true, alreadyExists: false } as const; + } catch (error) { + console.error(`[HackReview] Failed to create a review thread for ${slug}:`, error); + return { + ok: false, + error: "Failed to create the Discord review thread.", + } as const; + } +} + diff --git a/src/app/submit/actions.ts b/src/app/submit/actions.ts index d3a037a..a428df1 100644 --- a/src/app/submit/actions.ts +++ b/src/app/submit/actions.ts @@ -9,6 +9,11 @@ import { slugify } from "@/utils/format"; import { checkEditPermission, checkPatchEditPermission } from "@/utils/hack"; import { getCachedTagsWithUsage, resolveTagIdsInOrder } from "@/data/tags"; import type { PatchFormat } from "@/utils/patching"; +import { + ensureHackReviewThread, + getHackReviewThread, + postHackReviewMessage, +} from "@/utils/hack-review"; type HackInsert = TablesInsert<"hacks">; @@ -109,6 +114,36 @@ export async function prepareSubmission(formData: FormData) { return { ok: false, error: insertErr.message } as const; } + if (!is_archive) { + try { + const { data: profile } = await supabase.from("profiles").select("username").eq("id", user.id).single(); + const reviewThread = await ensureHackReviewThread({ + slug, + title, + author: profile?.username ? `@${profile.username}` : user.id, + isClaimed: false, + }); + if (!reviewThread && process.env.DISCORD_WEBHOOK_ADMIN_HACKS_URL) { + await sendDiscordMessageEmbed(process.env.DISCORD_WEBHOOK_ADMIN_HACKS_URL, [{ + title: `Review thread creation failed: ${title}`, + description: "The hack was saved, but its Discord review thread could not be created.", + color: 0xef4444, + url: `${process.env.NEXT_PUBLIC_SITE_URL}/hack/${slug}`, + }]); + } + } catch (error) { + console.error(`[HackReview] Failed to create a review thread for ${slug}:`, error); + if (process.env.DISCORD_WEBHOOK_ADMIN_HACKS_URL) { + await sendDiscordMessageEmbed(process.env.DISCORD_WEBHOOK_ADMIN_HACKS_URL, [{ + title: `Review thread creation failed: ${title}`, + description: "The hack was saved, but its Discord review thread could not be created.", + color: 0xef4444, + url: `${process.env.NEXT_PUBLIC_SITE_URL}/hack/${slug}`, + }]); + } + } + } + // Tags: restrict to existing only (order follows form submission) if (tags.length > 0) { const catalog = await getCachedTagsWithUsage(); @@ -120,24 +155,6 @@ export async function prepareSubmission(formData: FormData) { } } - if (process.env.DISCORD_WEBHOOK_ADMIN_HACKS_URL) { - const { data: profile } = await supabase.from('profiles').select('*').eq('id', user.id).single(); - const displayName = profile?.username ? `@${profile.username}` : user.id; - - const embed: APIEmbed = { - title: `Hack submission: ${title}`, - description: `A new hack by **${displayName}** is being prepared for submission.`, - color: 0x40f56a, - url: `${process.env.NEXT_PUBLIC_SITE_URL}/hack/${slug}`, - footer: { - text: `This message brought to you by Hackdex` - } - } - await sendDiscordMessageEmbed(process.env.DISCORD_WEBHOOK_ADMIN_HACKS_URL, [ - embed, - ]); - } - return { ok: true, slug } as const; } @@ -227,7 +244,7 @@ export async function confirmPatchUpload(args: { slug: string; objectKey: string const { data: hack, error: hErr } = await supabase .from("hacks") - .select("slug, created_by, title, current_patch, original_author, permission_from, is_archive, approved, verification_contact_info") + .select("slug, created_by, title, current_patch, original_author, permission_from, is_archive, approved, assigned_admin, verification_contact_info") .eq("slug", args.slug) .maybeSingle(); if (hErr) return { ok: false, error: hErr.message } as const; @@ -310,38 +327,58 @@ export async function confirmPatchUpload(args: { slug: string; objectKey: string } } - if (process.env.DISCORD_WEBHOOK_ADMIN_HACKS_URL) { - const { data: profile } = await supabase.from('profiles').select('*').eq('id', hack.created_by).single(); - const displayName = profile?.username ? `@${profile.username}` : hack.created_by; - const uploadedByDifferentUser = hack.created_by !== user.id; + const { data: profile } = await supabase + .from("profiles") + .select("username") + .eq("id", hack.created_by) + .single(); + const displayName = profile?.username ? `@${profile.username}` : hack.created_by; + const uploadedByDifferentUser = hack.created_by !== user.id; + const embed: APIEmbed = args.firstUpload ? { + title: `:tada: ${hack.title}`, + description: `A new hack by **${displayName}** is pending approval by an admin.` + + (uploadedByDifferentUser ? ` (Uploaded by ${user.id})` : "") + + (hack.verification_contact_info ? `\n\n**Verification contact info:**\n${hack.verification_contact_info}` : ""), + color: 0x40f56a, + url: `${process.env.NEXT_PUBLIC_SITE_URL}/hack/${args.slug}`, + footer: { text: "This message brought to you by Hackdex" }, + } : { + title: `New update for ${hack.title}`, + description: `**${hack.title}** has been updated to **${args.version}**`, + color: 0x40f56a, + url: `${process.env.NEXT_PUBLIC_SITE_URL}/hack/${args.slug}`, + footer: { + text: hack.approved + ? "This message brought to you by Hackdex" + : "This hack is still pending approval", + }, + }; - const embed: APIEmbed = args.firstUpload ? { - title: `:tada: ${hack.title}`, - description: `A new hack by **${displayName}** is pending approval by an admin.` - + (uploadedByDifferentUser ? ` (Uploaded by ${user.id})` : '') - + (hack.verification_contact_info ? `\n\n**Verification contact info:**\n${hack.verification_contact_info}` : ''), - color: 0x40f56a, - url: `${process.env.NEXT_PUBLIC_SITE_URL}/hack/${args.slug}`, - footer: { - text: `This message brought to you by Hackdex` - } - } : { - title: `New update for ${hack.title}`, - description: `**${hack.title}** has been updated to **${args.version}**`, - color: 0x40f56a, - url: `${process.env.NEXT_PUBLIC_SITE_URL}/hack/${args.slug}`, - footer: { - text: hack.approved ? `This message brought to you by Hackdex` : `This hack is still pending approval` + let reviewThread = null; + if (!hack.is_archive) { + try { + reviewThread = await getHackReviewThread(args.slug); + if (!reviewThread && args.firstUpload) { + reviewThread = await ensureHackReviewThread({ + slug: args.slug, + title: hack.title, + author: displayName, + isClaimed: hack.assigned_admin !== null, + }); } + } catch (error) { + console.error(`[HackReview] Failed to load or create the review thread for ${args.slug}:`, error); } + } - const webhookUrl = args.firstUpload || !hack.approved ? - process.env.DISCORD_WEBHOOK_ADMIN_HACKS_URL : - process.env.DISCORD_WEBHOOK_HACKDEX_HACKS_URL || process.env.DISCORD_WEBHOOK_ADMIN_HACKS_URL; + if (reviewThread) { + await postHackReviewMessage(reviewThread, { embeds: [embed] }); + } else if (process.env.DISCORD_WEBHOOK_ADMIN_HACKS_URL) { + await sendDiscordMessageEmbed(process.env.DISCORD_WEBHOOK_ADMIN_HACKS_URL, [embed]); + } - await sendDiscordMessageEmbed(webhookUrl, [ - embed, - ]); + if (hack.approved && process.env.DISCORD_WEBHOOK_HACKDEX_HACKS_URL) { + await sendDiscordMessageEmbed(process.env.DISCORD_WEBHOOK_HACKDEX_HACKS_URL, [embed]); } // Redirect to versions page if not publishing automatically, otherwise to hack page diff --git a/src/components/Hack/CreateReviewThreadMenuItem.tsx b/src/components/Hack/CreateReviewThreadMenuItem.tsx new file mode 100644 index 0000000..0cde0e5 --- /dev/null +++ b/src/components/Hack/CreateReviewThreadMenuItem.tsx @@ -0,0 +1,60 @@ +"use client"; + +import { MenuItem } from "@headlessui/react"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { FaDiscord } from "react-icons/fa6"; +import { toast } from "sonner"; + +import { createHackReviewThread } from "@/app/hack/actions"; + +interface CreateReviewThreadMenuItemProps { + slug: string; +} + +export default function CreateReviewThreadMenuItem({ + slug, +}: CreateReviewThreadMenuItemProps) { + const router = useRouter(); + const [isPending, setIsPending] = useState(false); + + const handleCreate = async () => { + if (isPending) return; + + setIsPending(true); + try { + const result = await createHackReviewThread(slug); + if (!result.ok) { + toast.error(result.error); + return; + } + + toast.success( + result.alreadyExists + ? "A Discord review thread already exists." + : "Discord review thread created.", + ); + router.refresh(); + } catch { + toast.error("Failed to create the Discord review thread."); + } finally { + setIsPending(false); + } + }; + + return ( + + + {isPending ? "Creating review thread…" : "Create review thread"} + + ); +} diff --git a/src/emails/render.ts b/src/emails/render.ts index 4ba8356..58c17d8 100644 --- a/src/emails/render.ts +++ b/src/emails/render.ts @@ -13,8 +13,16 @@ export type HackApprovedEmailVars = { slug: string; }; +export type HackReviewReplyEmailVars = { + title: string; + slug: string; + message: string; + adminName: string; +}; + export type EmailTemplateVars = { "hack-approved": HackApprovedEmailVars; + "hack-review-reply": HackReviewReplyEmailVars; }; export type EmailTemplate = keyof EmailTemplateVars; @@ -45,6 +53,12 @@ const templateNormalizers: { title: escapeHtml(title), slug: encodeURIComponent(slug), }), + "hack-review-reply": ({ title, slug, message, adminName }) => ({ + title: escapeHtml(title), + slug: encodeURIComponent(slug), + message: escapeHtml(message).replace(/\r?\n/g, "
"), + adminName: escapeHtml(adminName), + }), }; function normalizeTemplateVars( diff --git a/src/emails/templates/hack-review-reply.mjml b/src/emails/templates/hack-review-reply.mjml new file mode 100644 index 0000000..4471b91 --- /dev/null +++ b/src/emails/templates/hack-review-reply.mjml @@ -0,0 +1,37 @@ + + + + Hackdex has a review message about {{title}} + + + + + + + Review update for {{title}} + + + {{adminName}} from the Hackdex review team wrote: + + + + + + + {{message}} + + + + + + + View your hack + + + You can reply directly to this email. Your response will be sent to the Hackdex review team. + + + + + + diff --git a/src/types/db.ts b/src/types/db.ts index cd26288..19ac692 100644 --- a/src/types/db.ts +++ b/src/types/db.ts @@ -102,6 +102,44 @@ export type Database = { }, ] } + hack_review_threads: { + Row: { + created_at: string + discord_parent_channel_id: string + discord_thread_id: string + hack_slug: string + reply_token: string + resend_last_email_id: string | null + resend_last_message_id: string | null + } + Insert: { + created_at?: string + discord_parent_channel_id: string + discord_thread_id: string + hack_slug: string + reply_token: string + resend_last_email_id?: string | null + resend_last_message_id?: string | null + } + Update: { + created_at?: string + discord_parent_channel_id?: string + discord_thread_id?: string + hack_slug?: string + reply_token?: string + resend_last_email_id?: string | null + resend_last_message_id?: string | null + } + Relationships: [ + { + foreignKeyName: "hack_review_threads_hack_slug_fkey" + columns: ["hack_slug"] + isOneToOne: true + referencedRelation: "hacks" + referencedColumns: ["slug"] + }, + ] + } hack_tags: { Row: { hack_slug: string diff --git a/src/utils/discord-commands.mjs b/src/utils/discord-commands.mjs new file mode 100644 index 0000000..e6be058 --- /dev/null +++ b/src/utils/discord-commands.mjs @@ -0,0 +1,16 @@ +export const discordGuildCommands = [ + { + type: 1, + name: "reply", + description: "Email the submitter from this review thread", + options: [ + { + type: 3, + name: "message", + description: "Message to email to the submitter", + required: true, + max_length: 1800, + }, + ], + }, +]; diff --git a/src/utils/discord-rest.ts b/src/utils/discord-rest.ts new file mode 100644 index 0000000..68359c0 --- /dev/null +++ b/src/utils/discord-rest.ts @@ -0,0 +1,212 @@ +import type { APIEmbed } from "discord-api-types/v10"; +import { verifyKey } from "discord-interactions"; +import { discordGuildCommands } from "./discord-commands.mjs"; + +const DISCORD_API_BASE = "https://discord.com/api/v10"; + +type DiscordThread = { + id: string; + parent_id?: string | null; + applied_tags?: string[]; +}; + +type DiscordGuildCommand = { + name: string; +}; + +export class DiscordEnvironmentError extends Error { + constructor(missingEnvironment: string[]) { + super(`Missing Discord environment: ${missingEnvironment.join(", ")}`); + this.name = "DiscordEnvironmentError"; + } +} + +function getDiscordBotToken(): string | null { + const token = process.env.DISCORD_BOT_TOKEN; + if (!token) { + console.warn("[DiscordReview] DISCORD_BOT_TOKEN is missing; skipping Discord request."); + return null; + } + return token; +} + +async function discordRequest( + path: string, + init: RequestInit = {}, +): Promise { + const token = getDiscordBotToken(); + if (!token) return null; + + const response = await fetch(`${DISCORD_API_BASE}${path}`, { + ...init, + headers: { + Authorization: `Bot ${token}`, + "Content-Type": "application/json", + "User-Agent": "HackdexReviewBot (https://hackdex.app, 1.0)", + ...init.headers, + }, + }); + + if (!response.ok) { + const detail = await response.text(); + throw new Error(`Discord API ${response.status}: ${detail}`); + } + + if (response.status === 204) return null; + return response.json() as Promise; +} + +export async function registerDiscordGuildCommands(): Promise< + DiscordGuildCommand[] +> { + const requiredEnvironment = [ + "DISCORD_APPLICATION_ID", + "DISCORD_GUILD_ID", + "DISCORD_BOT_TOKEN", + ] as const; + const missingEnvironment = requiredEnvironment.filter( + (name) => !process.env[name], + ); + if (missingEnvironment.length > 0) { + throw new DiscordEnvironmentError(missingEnvironment); + } + + const registered = await discordRequest( + `/applications/${process.env.DISCORD_APPLICATION_ID}/guilds/${process.env.DISCORD_GUILD_ID}/commands`, + { + method: "PUT", + body: JSON.stringify(discordGuildCommands), + }, + ); + if (!registered) { + throw new Error("Discord command registration returned no result."); + } + return registered; +} + +export async function verifyDiscordRequest( + rawBody: string, + signature: string | null, + timestamp: string | null, +): Promise { + const publicKey = process.env.DISCORD_PUBLIC_KEY; + if (!publicKey) { + console.warn("[DiscordReview] DISCORD_PUBLIC_KEY is missing; cannot verify interaction."); + return false; + } + if (!signature || !timestamp) return false; + try { + return await verifyKey(rawBody, signature, timestamp, publicKey); + } catch { + return false; + } +} + +export async function createDiscordReviewThread(args: { + title: string; + slug: string; + author?: string | null; + isClaimed: boolean; +}): Promise { + const forumChannelId = process.env.DISCORD_REVIEW_FORUM_CHANNEL_ID; + const pendingTagId = process.env.DISCORD_FORUM_TAG_PENDING_ID; + if (!forumChannelId || !pendingTagId) { + console.warn( + "[DiscordReview] DISCORD_REVIEW_FORUM_CHANNEL_ID or DISCORD_FORUM_TAG_PENDING_ID is missing; skipping review thread creation.", + ); + return null; + } + + const title = Array.from(args.title).slice(0, 100).join(""); + const siteUrl = process.env.NEXT_PUBLIC_SITE_URL?.replace(/\/$/, ""); + const author = args.author ? ` by **${args.author}**` : ""; + const url = siteUrl ? `${siteUrl}/hack/${encodeURIComponent(args.slug)}` : ""; + const claimTagId = args.isClaimed + ? process.env.DISCORD_FORUM_TAG_CLAIMED_ID + : process.env.DISCORD_FORUM_TAG_UNCLAIMED_ID; + if (!claimTagId) { + console.warn( + `[DiscordReview] ${args.isClaimed ? "DISCORD_FORUM_TAG_CLAIMED_ID" : "DISCORD_FORUM_TAG_UNCLAIMED_ID"} is missing; creating review thread without a claim tag.`, + ); + } + + return discordRequest(`/channels/${forumChannelId}/threads`, { + method: "POST", + body: JSON.stringify({ + name: title, + auto_archive_duration: 10080, + applied_tags: claimTagId ? [pendingTagId, claimTagId] : [pendingTagId], + message: { + embeds: [{ + title: args.title, + description: `A new hack${author} is being reviewed.${url ? `\n${url}` : ""}`, + url: url || undefined, + color: 0x40f56a, + }], + }, + }), + }); +} + +export async function getDiscordThread(threadId: string): Promise { + return discordRequest(`/channels/${threadId}`); +} + +export async function postDiscordThreadMessage( + threadId: string, + message: { content?: string; embeds?: APIEmbed[] }, +): Promise { + const result = await discordRequest<{ id: string }>(`/channels/${threadId}/messages`, { + method: "POST", + body: JSON.stringify(message), + }); + return result !== null; +} + +export async function approveDiscordReviewThread(threadId: string): Promise { + const pendingTagId = process.env.DISCORD_FORUM_TAG_PENDING_ID; + const approvedTagId = process.env.DISCORD_FORUM_TAG_APPROVED_ID; + if (!pendingTagId || !approvedTagId) { + console.warn( + "[DiscordReview] DISCORD_FORUM_TAG_PENDING_ID or DISCORD_FORUM_TAG_APPROVED_ID is missing; skipping review tag update.", + ); + return false; + } + + const thread = await getDiscordThread(threadId); + if (!thread) return false; + + const appliedTags = new Set(thread.applied_tags ?? []); + appliedTags.delete(pendingTagId); + appliedTags.add(approvedTagId); + + const updated = await discordRequest(`/channels/${threadId}`, { + method: "PATCH", + body: JSON.stringify({ applied_tags: Array.from(appliedTags) }), + }); + return updated !== null; +} + +export async function claimDiscordReviewThread(threadId: string): Promise { + const claimedTagId = process.env.DISCORD_FORUM_TAG_CLAIMED_ID; + const unclaimedTagId = process.env.DISCORD_FORUM_TAG_UNCLAIMED_ID; + if (!claimedTagId || !unclaimedTagId) { + console.warn( + "[DiscordReview] DISCORD_FORUM_TAG_CLAIMED_ID or DISCORD_FORUM_TAG_UNCLAIMED_ID is missing; skipping review claim tag update.", + ); + return false; + } + + const thread = await getDiscordThread(threadId); + if (!thread) return false; + + const appliedTags = new Set(thread.applied_tags ?? []); + appliedTags.delete(unclaimedTagId); + appliedTags.add(claimedTagId); + + const updated = await discordRequest(`/channels/${threadId}`, { + method: "PATCH", + body: JSON.stringify({ applied_tags: Array.from(appliedTags) }), + }); + return updated !== null; +} diff --git a/src/utils/hack-review.ts b/src/utils/hack-review.ts new file mode 100644 index 0000000..d54e030 --- /dev/null +++ b/src/utils/hack-review.ts @@ -0,0 +1,196 @@ +import { randomBytes } from "node:crypto"; + +import type { APIEmbed } from "discord-api-types/v10"; +import { Resend } from "resend"; + +import { renderEmail } from "@/emails/render"; +import type { Tables } from "@/types/db"; +import { + createDiscordReviewThread, + postDiscordThreadMessage, +} from "@/utils/discord-rest"; +import { createServiceClient } from "@/utils/supabase/server"; + +export type HackReviewThread = Tables<"hack_review_threads">; + +export async function getHackReviewThread( + hackSlug: string, +): Promise { + const serviceClient = await createServiceClient(); + const { data, error } = await serviceClient + .from("hack_review_threads") + .select("*") + .eq("hack_slug", hackSlug) + .maybeSingle(); + if (error) throw error; + return data; +} + +export async function ensureHackReviewThread(args: { + slug: string; + title: string; + author?: string | null; + isArchive?: boolean; + isClaimed: boolean; +}): Promise { + if (args.isArchive) return null; + + const existing = await getHackReviewThread(args.slug); + if (existing) return existing; + + const discordThread = await createDiscordReviewThread(args); + if (!discordThread) return null; + + const serviceClient = await createServiceClient(); + const { data, error } = await serviceClient + .from("hack_review_threads") + .insert({ + hack_slug: args.slug, + discord_thread_id: discordThread.id, + discord_parent_channel_id: + discordThread.parent_id ?? process.env.DISCORD_REVIEW_FORUM_CHANNEL_ID!, + reply_token: randomBytes(24).toString("hex"), + }) + .select("*") + .single(); + + if (!error) return data; + + const { data: racedRow } = await serviceClient + .from("hack_review_threads") + .select("*") + .eq("hack_slug", args.slug) + .maybeSingle(); + if (racedRow) return racedRow; + throw error; +} + +export async function postHackReviewMessage( + reviewThread: HackReviewThread, + message: { content?: string; embeds?: APIEmbed[] }, +): Promise<"posted" | "failed"> { + try { + const posted = await postDiscordThreadMessage( + reviewThread.discord_thread_id, + message, + ); + if (!posted) { + console.warn( + `[HackReview] Discord was not configured; skipped posting to thread ${reviewThread.discord_thread_id}.`, + ); + return "failed"; + } + return "posted"; + } catch (error) { + console.error( + `[HackReview] Failed to post to existing Discord thread ${reviewThread.discord_thread_id}:`, + error, + ); + return "failed"; + } +} + +export async function emailHackCreator(args: { + hackSlug: string; + message: string; + adminName: string; +}): Promise< + | { ok: true; email: string; subject: string; creatorUsername: string | null } + | { ok: false; error: string } +> { + const apiKey = process.env.RESEND_API_KEY; + const inboundDomain = process.env.RESEND_INBOUND_DOMAIN; + if (!apiKey || !inboundDomain) { + console.warn( + "[HackReview] RESEND_API_KEY or RESEND_INBOUND_DOMAIN is missing; skipping review email.", + ); + return { ok: false, error: "Review email is not configured." }; + } + + const serviceClient = await createServiceClient(); + const { data: row, error } = await serviceClient + .from("hack_review_threads") + .select("*, hacks!inner(title, created_by)") + .eq("hack_slug", args.hackSlug) + .single(); + if (error || !row) { + console.error("[HackReview] Failed to load review email context:", error); + return { ok: false, error: "Review thread was not found." }; + } + + const { data: creatorData, error: creatorError } = + await serviceClient.auth.admin.getUserById(row.hacks.created_by); + const creatorEmail = creatorData?.user?.email; + if (creatorError || !creatorEmail) { + console.error( + "[HackReview] Failed to load the hack creator email:", + creatorError ?? "No email found", + ); + return { ok: false, error: "Submitter email was not found." }; + } + + const { data: creatorProfile, error: profileError } = await serviceClient + .from("profiles") + .select("username") + .eq("id", row.hacks.created_by) + .maybeSingle(); + if (profileError) { + console.warn( + "[HackReview] Failed to load the hack creator profile:", + profileError, + ); + } + + const resend = new Resend(apiKey); + const previousMessageId = row.resend_last_message_id; + const subject = `${previousMessageId ? "Re: " : ""}${row.hacks.title} - Hackdex review`; + const html = await renderEmail("hack-review-reply", { + title: row.hacks.title, + slug: args.hackSlug, + message: args.message, + adminName: args.adminName, + }); + const headers = previousMessageId + ? { + "In-Reply-To": previousMessageId, + References: previousMessageId, + } + : undefined; + + const { data: sent, error: sendError } = await resend.emails.send({ + from: process.env.RESEND_FROM ?? `reviews@${inboundDomain}`, + to: creatorEmail, + replyTo: `reviews+${row.reply_token}@${inboundDomain}`, + subject, + html, + text: `${args.adminName} wrote about ${row.hacks.title}:\n\n${args.message}\n\nReply to this email to respond to the Hackdex review team.`, + headers, + }); + if (sendError || !sent) { + console.error("[HackReview] Resend failed to send review email:", sendError); + return { ok: false, error: "Failed to send the review email." }; + } + + const { data: sentEmail, error: getError } = await resend.emails.get(sent.id); + if (getError) { + console.warn("[HackReview] Could not fetch the sent email Message-ID:", getError); + } + + const { error: updateError } = await serviceClient + .from("hack_review_threads") + .update({ + resend_last_email_id: sent.id, + resend_last_message_id: sentEmail?.message_id ?? previousMessageId, + }) + .eq("hack_slug", args.hackSlug); + if (updateError) { + console.error("[HackReview] Failed to persist Resend message metadata:", updateError); + } + + return { + ok: true, + email: creatorEmail, + subject, + creatorUsername: creatorProfile?.username ?? null, + }; +} diff --git a/supabase/migrations/20260822020000_hack_review_threads.sql b/supabase/migrations/20260822020000_hack_review_threads.sql new file mode 100644 index 0000000..bd3a534 --- /dev/null +++ b/supabase/migrations/20260822020000_hack_review_threads.sql @@ -0,0 +1,11 @@ +create table public.hack_review_threads ( + hack_slug text primary key references public.hacks(slug) on update cascade on delete cascade, + discord_thread_id text not null unique, + discord_parent_channel_id text not null, + reply_token text not null unique, + resend_last_email_id text, + resend_last_message_id text, + created_at timestamptz not null default now() +); + +alter table public.hack_review_threads enable row level security;