mirror of
https://github.com/Hackdex-App/hackdex-website.git
synced 2026-08-23 00:54:14 -05:00
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
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:
18
README.md
18
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):
|
||||
|
||||
60
package-lock.json
generated
60
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
33
scripts/register-discord-commands.mjs
Normal file
33
scripts/register-discord-commands.mjs
Normal 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).`);
|
||||
223
src/app/api/discord/interactions/route.ts
Normal file
223
src/app/api/discord/interactions/route.ts
Normal 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;
|
||||
}
|
||||
36
src/app/api/discord/register/route.ts
Normal file
36
src/app/api/discord/register/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
242
src/app/api/resend/inbound/route.ts
Normal file
242
src/app/api/resend/inbound/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
60
src/components/Hack/CreateReviewThreadMenuItem.tsx
Normal file
60
src/components/Hack/CreateReviewThreadMenuItem.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>(
|
||||
|
||||
37
src/emails/templates/hack-review-reply.mjml
Normal file
37
src/emails/templates/hack-review-reply.mjml
Normal 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>
|
||||
@@ -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
|
||||
|
||||
16
src/utils/discord-commands.mjs
Normal file
16
src/utils/discord-commands.mjs
Normal 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
212
src/utils/discord-rest.ts
Normal 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
196
src/utils/hack-review.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
11
supabase/migrations/20260822020000_hack_review_threads.sql
Normal file
11
supabase/migrations/20260822020000_hack_review_threads.sql
Normal 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;
|
||||
Reference in New Issue
Block a user