Add Discord review threads and a Resend reply loop for submitted hacks (#76)
Some checks are pending
Deploy Supabase Migrations to Production / migrate (push) Waiting to run

* Add a Discord review thread and Resend reply loop for submitted hacks.

Admins can email submitters from a forum thread and inbound replies land back in that same thread.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Add an admin endpoint to register Discord guild commands.

Local still uses the npm script; production can hit /api/discord/register while logged in as an admin.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Add an admin backup to create a missing Discord review thread.

If submit fails to open a thread, an admin can create one from the hack details menu.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Match inbound review replies when the plus-address token is lowercased.

Mail delivers To in lowercase, so tokens are now hex and lookups are case-insensitive.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Use distinct review embeds for /reply and inbound creator mail.

/reply posts a green embed with subject, Discord avatar, and the creator username; thread replies drop the To field.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Gate /reply by Discord role and mark inbound From as verified.

DISCORD_REPLY_ROLE_IDS is required; creator mail still posts when the address does not match the account.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Retry inbound review mail when Discord does not post the embed.

A failed thread post no longer records the email as processed or returns 200 to Resend.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Notify the admin webhook when a review thread cannot be loaded.

Patch upload no longer stays silent if getHackReviewThread throws.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Keep the /reply ack honest after the review email is sent.

A later Discord or deferred-response failure no longer claims the email failed.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Make the admin review message stand out in the reply email.

The quote sits in a rose-tinted box so it is not just another paragraph.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Tag review threads as claimed or unclaimed and announce claims.

New and backup-created threads get the matching forum tag; dashboard claims swap Unclaimed to Claimed.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jared Schoeny
2026-08-22 22:07:33 -06:00
committed by GitHub
parent d07230a0c4
commit e0747efb82
19 changed files with 1411 additions and 46 deletions

View File

@@ -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):

60
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -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).`);

View File

@@ -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<void> {
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;
}

View File

@@ -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 });
}
}

View File

@@ -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<string>();
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<string, string> | 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 });
}
}

View File

@@ -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,
);
}
}),
);
};

View File

@@ -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
</MenuItem>
)}
{isAdmin && !isArchive && !hasReviewThread && (
<CreateReviewThreadMenuItem slug={hack.slug} />
)}
</HackOptionsMenu>
</div>
</div>

View File

@@ -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;
}
}

View File

@@ -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

View File

@@ -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 (
<MenuItem
as="button"
type="button"
disabled={isPending}
onClick={handleCreate}
className="block w-full px-3 py-2 text-left text-sm text-foreground/80 font-medium data-focus:bg-black/5 dark:data-focus:bg-white/10 disabled:opacity-50"
>
<FaDiscord
className="mr-2 inline-block align-middle mb-0.5 text-foreground/80"
size={12}
/>
{isPending ? "Creating review thread…" : "Create review thread"}
</MenuItem>
);
}

View File

@@ -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, "<br />"),
adminName: escapeHtml(adminName),
}),
};
function normalizeTemplateVars<T extends EmailTemplate>(

View File

@@ -0,0 +1,37 @@
<mjml>
<mj-head>
<mj-include path="../partials/head.mjml" />
<mj-preview>Hackdex has a review message about {{title}}</mj-preview>
</mj-head>
<mj-body background-color="#f3f4f6">
<mj-include path="../partials/header.mjml" />
<mj-section background-color="#ffffff">
<mj-column>
<mj-text font-size="24px" font-weight="700" color="#171717" padding-bottom="12px">
Review update for {{title}}
</mj-text>
<mj-text font-size="16px" line-height="24px" color="#374151" padding-bottom="16px">
<strong>{{adminName}}</strong> from the Hackdex review team wrote:
</mj-text>
</mj-column>
</mj-section>
<mj-section background-color="#ffffff" padding="0 20px 8px">
<mj-column background-color="#fff1f2" border-left="4px solid #f43f5e" padding="16px 20px" border-radius="6px">
<mj-text font-size="16px" line-height="24px" color="#171717" padding="0">
{{message}}
</mj-text>
</mj-column>
</mj-section>
<mj-section background-color="#ffffff">
<mj-column>
<mj-button href="https://hackdex.app/hack/{{slug}}" padding="20px 25px">
<strong>View your hack</strong>
</mj-button>
<mj-text font-size="16px" line-height="24px" color="#374151">
You can reply directly to this email. Your response will be sent to the Hackdex review team.
</mj-text>
</mj-column>
</mj-section>
<mj-include path="../partials/sign-off.mjml" />
</mj-body>
</mjml>

View File

@@ -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

View File

@@ -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,
},
],
},
];

212
src/utils/discord-rest.ts Normal file
View File

@@ -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<T>(
path: string,
init: RequestInit = {},
): Promise<T | null> {
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<T>;
}
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<DiscordGuildCommand[]>(
`/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<boolean> {
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<DiscordThread | null> {
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<DiscordThread>(`/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<DiscordThread | null> {
return discordRequest<DiscordThread>(`/channels/${threadId}`);
}
export async function postDiscordThreadMessage(
threadId: string,
message: { content?: string; embeds?: APIEmbed[] },
): Promise<boolean> {
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<boolean> {
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<DiscordThread>(`/channels/${threadId}`, {
method: "PATCH",
body: JSON.stringify({ applied_tags: Array.from(appliedTags) }),
});
return updated !== null;
}
export async function claimDiscordReviewThread(threadId: string): Promise<boolean> {
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<DiscordThread>(`/channels/${threadId}`, {
method: "PATCH",
body: JSON.stringify({ applied_tags: Array.from(appliedTags) }),
});
return updated !== null;
}

196
src/utils/hack-review.ts Normal file
View File

@@ -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<HackReviewThread | null> {
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<HackReviewThread | null> {
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,
};
}

View File

@@ -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;