diff --git a/package-lock.json b/package-lock.json index ab5579f..3d032c3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 80a5436..6a2b700 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/app/account/actions.ts b/src/app/account/actions.ts index 3b1ddb2..6931281 100644 --- a/src/app/account/actions.ts +++ b/src/app/account/actions.ts @@ -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 { + 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; +} diff --git a/src/app/signup/actions.ts b/src/app/signup/actions.ts index 2ec48ca..7a33677 100644 --- a/src/app/signup/actions.ts +++ b/src/app/signup/actions.ts @@ -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' + } + }, + ]); + } } } diff --git a/src/app/submit/actions.ts b/src/app/submit/actions.ts index fba2421..1746ade 100644 --- a/src/app/submit/actions.ts +++ b/src/app/submit/actions.ts @@ -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; } diff --git a/src/components/Account/AccountSetupForm.tsx b/src/components/Account/AccountSetupForm.tsx index 85fbce4..eaf891e 100644 --- a/src/components/Account/AccountSetupForm.tsx +++ b/src/components/Account/AccountSetupForm.tsx @@ -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() diff --git a/src/components/Hack/HackSubmitForm.tsx b/src/components/Hack/HackSubmitForm.tsx index b9e327b..0d72d90 100644 --- a/src/components/Hack/HackSubmitForm.tsx +++ b/src/components/Hack/HackSubmitForm.tsx @@ -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) { diff --git a/src/utils/discord.ts b/src/utils/discord.ts new file mode 100644 index 0000000..5e48c50 --- /dev/null +++ b/src/utils/discord.ts @@ -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); +};