Add Discord admin webhook notifications

This commit is contained in:
Jared Schoeny
2025-11-07 17:50:20 -10:00
parent 39bda37d11
commit c7a174fea0
8 changed files with 145 additions and 9 deletions

11
package-lock.json generated
View File

@@ -40,6 +40,7 @@
"@types/nodemailer": "^7.0.3",
"@types/react": "^19",
"@types/react-dom": "^19",
"discord-api-types": "^0.38.32",
"patch-package": "^8.0.0",
"raw-loader": "^4.0.2",
"tailwindcss": "^4",
@@ -3728,6 +3729,16 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/discord-api-types": {
"version": "0.38.32",
"resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.32.tgz",
"integrity": "sha512-UhIqkFuUVwBzejLPPWF18qixYPucMf718RnGh1NxZYNS7czXUmcUsWWkzWR7lRWj5pjfj4LwrnN9McvpfLvGqQ==",
"dev": true,
"license": "MIT",
"workspaces": [
"scripts/actions/documentation"
]
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",

View File

@@ -41,6 +41,7 @@
"@types/nodemailer": "^7.0.3",
"@types/react": "^19",
"@types/react-dom": "^19",
"discord-api-types": "^0.38.32",
"patch-package": "^8.0.0",
"raw-loader": "^4.0.2",
"tailwindcss": "^4",

View File

@@ -1,5 +1,6 @@
"use server";
import { sendDiscordMessageEmbed } from "@/utils/discord";
import { createClient } from "@/utils/supabase/server";
export type UpdateState =
@@ -23,3 +24,44 @@ export async function updatePassword(state: UpdateState, payload: FormData): Pro
return { ok: true, error: null } as const;
}
export async function setupProfile(username: string): Promise<UpdateState> {
const supabase = await createClient();
const {
data: { user },
error: authError,
} = await supabase.auth.getUser();
if (authError || !user) {
return { ok: false, error: "You must be logged in to set up your profile." } as const;
}
if (!username || username.length < 3 || username.length > 20) {
return { ok: false, error: "Username must be between 3 and 20 characters." } as const;
}
const { error } = await supabase.from("profiles").upsert({
id: user.id,
username,
updated_at: new Date().toISOString(),
});
if (error) {
return { ok: false, error: error.message || "There was an error saving. Please try again." } as const;
}
if (process.env.DISCORD_WEBHOOK_ADMIN_URL) {
await sendDiscordMessageEmbed(process.env.DISCORD_WEBHOOK_ADMIN_URL, [
{
title: 'New Profile Setup',
description: `A new user (\`${user.id}\`) has setup their profile with the username: \`@${username}\``,
color: 0x40f56a,
footer: {
text: 'This message brought to you by Hackdex'
}
},
]);
}
return { ok: true, error: null } as const;
}

View File

@@ -7,6 +7,7 @@ import { get } from '@vercel/edge-config'
import { createClient, createServiceClient } from '@/utils/supabase/server'
import { validateEmail, validatePassword } from '@/utils/auth'
import { sendDiscordMessageEmbed } from '@/utils/discord'
function getErrorMessage(error: AuthError): string {
const code = (error.code)?.toLowerCase()
@@ -87,6 +88,18 @@ export async function signup(state: AuthActionState, payload: FormData) {
const userId = signUpResult.user?.id || null
if (isStaticInvite) {
console.log('[signup] Static invite code used:', { inviteCode, userId })
if (process.env.DISCORD_WEBHOOK_ADMIN_URL) {
await sendDiscordMessageEmbed(process.env.DISCORD_WEBHOOK_ADMIN_URL, [
{
title: 'New User Signup',
description: `A new user (\`${userId}\`) has signed up using the static invite code: \`${inviteCode}\``,
color: 0x40f56a,
footer: {
text: 'A notification will be sent when this user has created their profile'
}
},
]);
}
} else {
// Finalize: set used_by to the new user id iff still unused (atomic)
const { data: finalized, error: finalizeError } = await service
@@ -105,6 +118,19 @@ export async function signup(state: AuthActionState, payload: FormData) {
} catch {}
}
return { error: 'Invite code is no longer available. Please try again.' }
} else {
if (process.env.DISCORD_WEBHOOK_ADMIN_URL) {
await sendDiscordMessageEmbed(process.env.DISCORD_WEBHOOK_ADMIN_URL, [
{
title: 'New User Signup',
description: `A new user (\`${userId}\`) has signed up using the invite code: \`${inviteCode}\``,
color: 0x40f56a,
footer: {
text: 'A notification will be sent when this user has created their profile'
}
},
]);
}
}
}

View File

@@ -3,6 +3,8 @@
import { createClient } from "@/utils/supabase/server";
import type { TablesInsert } from "@/types/db";
import { getMinioClient, PATCHES_BUCKET } from "@/utils/minio/server";
import { sendDiscordMessageEmbed } from "@/utils/discord";
import { APIEmbed } from "discord-api-types/v10";
type HackInsert = TablesInsert<"hacks">;
@@ -152,7 +154,7 @@ export async function presignPatchAndSaveCovers(args: {
return { ok: true, presignedUrl: url, objectKey } as const;
}
export async function confirmPatchUpload(args: { slug: string; objectKey: string; version: string }) {
export async function confirmPatchUpload(args: { slug: string; objectKey: string; version: string, firstUpload?: boolean }) {
const supabase = await createClient();
const {
data: { user },
@@ -161,7 +163,7 @@ export async function confirmPatchUpload(args: { slug: string; objectKey: string
const { data: hack, error: hErr } = await supabase
.from("hacks")
.select("slug, created_by")
.select("slug, created_by, title")
.eq("slug", args.slug)
.maybeSingle();
if (hErr) return { ok: false, error: hErr.message } as const;
@@ -192,6 +194,33 @@ export async function confirmPatchUpload(args: { slug: string; objectKey: string
.eq("slug", args.slug);
if (uErr) return { ok: false, error: uErr.message } as const;
if (process.env.DISCORD_WEBHOOK_ADMIN_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 = args.firstUpload ? {
title: `:tada: ${hack.title}`,
description: `A new hack by **${displayName}** is pending approval by an admin.`,
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: `This message brought to you by Hackdex`
}
}
await sendDiscordMessageEmbed(process.env.DISCORD_WEBHOOK_ADMIN_URL, [
embed,
]);
}
return { ok: true, patchId: patch.id, redirectTo: `/hack/${args.slug}` } as const;
}

View File

@@ -5,6 +5,7 @@ import { createClient } from '@/utils/supabase/client'
import { type User } from '@supabase/supabase-js'
import { useRouter } from 'next/navigation'
import { FiCheck, FiX, FiCopy } from 'react-icons/fi'
import { setupProfile } from '@/app/account/actions'
type UsernameState =
| { status: 'idle'; message: string }
@@ -114,13 +115,12 @@ export default function AccountSetupForm({ user }: { user: User }) {
try {
setSubmitting(true)
const { error } = await supabase.from('profiles').upsert({
id: user.id,
username,
updated_at: new Date().toISOString(),
})
const result = await setupProfile(username)
if (error) throw error
if (!result || !result.ok) {
setUsernameState({ status: 'invalid', message: result?.error || 'There was an error saving. Please try again.' })
return
}
// Refresh SSR boundary so the page re-renders into the update form state
router.refresh()

View File

@@ -410,7 +410,7 @@ export default function HackSubmitForm({ dummy = false }: HackSubmitFormProps) {
if (patchFile) {
await fetch(presigned.presignedUrl, { method: 'PUT', body: patchFile, headers: { 'Content-Type': 'application/octet-stream' } });
const finalized = await confirmPatchUpload({ slug: prepared.slug, objectKey: presigned.objectKey!, version });
const finalized = await confirmPatchUpload({ slug: prepared.slug, objectKey: presigned.objectKey!, version, firstUpload: true });
if (!finalized.ok) throw new Error(finalized.error || 'Failed to finalize');
try {
if (draftKey) {

27
src/utils/discord.ts Normal file
View File

@@ -0,0 +1,27 @@
import type { APIEmbed, RESTPostAPIWebhookWithTokenJSONBody } from "discord-api-types/v10";
export const sendDiscordMessage = async (url: string, message: RESTPostAPIWebhookWithTokenJSONBody) => {
const response = await fetch(url, {
method: "POST",
body: JSON.stringify(message),
headers: {
"Content-Type": "application/json",
},
});
return response;
};
export const sendDiscordMessageEmbed = async (url: string, embeds: APIEmbed[]) => {
const message: RESTPostAPIWebhookWithTokenJSONBody = {
embeds,
};
return sendDiscordMessage(url, message);
};
export const sendDiscordMessageText = async (url: string, content: string) => {
const message: RESTPostAPIWebhookWithTokenJSONBody = {
content,
};
return sendDiscordMessage(url, message);
};