From b349a8693dafcc5139f23f544a122c096b0156b1 Mon Sep 17 00:00:00 2001 From: Melvin Jones Repol Date: Tue, 28 Jul 2026 12:49:06 +0800 Subject: [PATCH 1/2] feat(core): migrated our database from supabase to mysql with prisma, auth is now powered by nextauth, remove hcaptcha and build workflow --- .env.example | 34 +- .github/workflows/build.yml | 60 - .gitignore | 2 + app/(public)/flex/page.tsx | 50 +- app/(public)/join/[code]/page.tsx | 40 +- app/(public)/join/page.tsx | 83 +- app/(public)/leaderboard/[slug]/page.tsx | 75 +- app/(public)/leaderboard/page.tsx | 75 +- app/api/admin/stats/route.ts | 50 + app/api/auth/[...nextauth]/route.ts | 3 + app/api/auth/callback/route.ts | 43 - app/api/auth/forgot-password/route.ts | 29 + app/api/auth/register/route.ts | 41 + app/api/auth/reset-password/route.ts | 44 + app/api/auth/update-password/route.ts | 29 + app/api/conversations/[id]/presence/route.ts | 33 + app/api/conversations/[id]/route.ts | 29 + app/api/conversations/[id]/unread/route.ts | 37 + app/api/conversations/route.ts | 103 + app/api/cron/cleanup-messages/route.ts | 15 + app/api/flex/[id]/route.ts | 61 + app/api/flex/projects/route.ts | 17 + app/api/flex/route.ts | 59 + app/api/kanban/data/route.ts | 43 + app/api/kanban/issues/[id]/route.ts | 46 + app/api/kanban/issues/route.ts | 61 + app/api/leaderboards/[id]/join-code/route.ts | 56 + app/api/leaderboards/[id]/leave/route.ts | 21 + app/api/leaderboards/[id]/route.ts | 32 + app/api/leaderboards/join/route.ts | 47 + app/api/leaderboards/route.ts | 50 + app/api/messages/route.ts | 113 + app/api/presence/route.ts | 19 + app/api/profile/route.ts | 26 + app/api/sse/chat/[conversationId]/route.ts | 54 + .../sse/chat/[conversationId]/typing/route.ts | 40 + app/api/sse/conversations/route.ts | 40 + app/api/sse/kanban/route.ts | 34 + app/api/users/badges/route.ts | 29 + app/api/users/route.ts | 35 + app/api/wakatime/sync/route.ts | 36 +- app/components/BoardList.tsx | 206 +- app/components/BrowserCheck.tsx | 2 +- app/components/Chat.tsx | 946 ++++---- app/components/Flex.tsx | 194 +- app/components/JoinButton.tsx | 44 +- app/components/admin/Dashbord.tsx | 65 +- app/components/admin/Widgets/UserLists.tsx | 9 +- app/components/auth/Logout.tsx | 7 +- app/components/auth/Oauth2.tsx | 32 +- .../auth/form/ForgotPasswordForm.tsx | 57 +- app/components/auth/form/LoginForm.tsx | 64 +- .../auth/form/ResetPasswordForm.tsx | 104 +- app/components/auth/form/SignupForm.tsx | 60 +- .../auth/form/UpdatePasswordForm.tsx | 31 +- app/components/chat/Conversations.tsx | 8 +- app/components/chat/MediaViewerModal.tsx | 17 +- app/components/chat/Messages.tsx | 188 +- app/components/chat/Player.tsx | 1 - .../chat/hooks/useActiveConversationStream.ts | 384 +-- .../chat/hooks/useChatAttachmentInput.ts | 23 +- app/components/chat/hooks/useChatBadWords.ts | 2 +- app/components/chat/hooks/useChatBadges.ts | 39 +- .../chat/hooks/useChatConversationActions.ts | 185 +- .../hooks/useChatConversationsRealtime.ts | 404 +--- .../chat/hooks/useChatInputBehavior.ts | 7 +- .../chat/hooks/useChatMessageComposer.ts | 186 +- app/components/chat/hooks/useChatPresence.ts | 93 +- app/components/chat/hooks/useChatTyping.ts | 84 +- .../chat/hooks/useChatUserPicker.ts | 35 +- app/components/common/ui/CTA.tsx | 5 +- app/components/dashboard/LeaderbordList.tsx | 125 +- app/components/dashboard/Navbar.tsx | 4 +- app/components/dashboard/Settings/Profile.tsx | 61 +- .../dashboard/Settings/ResetPassword.tsx | 34 +- app/components/dashboard/WithKey.tsx | 200 +- app/components/dashboard/WithoutKey.tsx | 13 +- .../dashboard/widgets/Categories.tsx | 2 +- .../dashboard/widgets/CodingActivity.tsx | 2 +- .../widgets/CodingConsistencyHeatmap.tsx | 42 +- .../dashboard/widgets/Dependencies.tsx | 10 +- .../widgets/LanguageDestribution.tsx | 9 +- app/components/dashboard/widgets/Machines.tsx | 2 +- app/components/dashboard/widgets/Projects.tsx | 26 +- .../dashboard/widgets/StatsCard.tsx | 17 +- app/components/landing-page/LosserMembers.tsx | 12 +- .../landing-page/RecentLeaderboard.tsx | 13 +- app/components/landing-page/TopLeaderbord.tsx | 9 +- app/components/landing-page/VibeCoders.tsx | 5 +- app/components/layout/Nav.tsx | 15 +- app/components/leaderboard/BackButton.tsx | 6 +- app/components/leaderboard/Banner.tsx | 30 +- app/components/leaderboard/Header.tsx | 42 +- .../leaderboard/InviteFriendsButton.tsx | 8 +- .../leaderboard/LeaderboardStats.tsx | 45 +- .../leaderboard/LeaderboardTable.tsx | 20 +- app/d/admin/page.tsx | 10 +- app/d/chat/page.tsx | 4 +- app/d/flex/page.tsx | 6 +- app/d/kanban/page.tsx | 563 +++-- app/d/layout.tsx | 17 +- app/d/leaderboards/page.tsx | 12 +- app/d/page.tsx | 8 +- app/d/settings/page.tsx | 28 +- app/d/update-password/page.tsx | 1 - app/globals.css | 392 +-- app/hooks/useBadWords.ts | 2 +- app/internal-server-error.tsx | 6 +- app/lib/auth.ts | 123 + app/lib/auth/user.ts | 22 + app/lib/emitter.ts | 8 + app/lib/prisma.ts | 7 + app/lib/proxy/auth.ts | 57 +- app/lib/supabase/client.ts | 9 - app/lib/supabase/help/user.ts | 20 - app/lib/supabase/public.ts | 12 - app/lib/supabase/server.ts | 21 - app/lib/wakatime/repository.ts | 86 +- app/lib/wakatime/sync.ts | 217 +- app/page.tsx | 95 +- app/sitemaps/leaderboards.ts | 18 +- app/supabase-types.ts | 524 ---- app/utils/badge.ts | 2 +- app/utils/media.ts | 2 +- app/utils/moderation.ts | 2 +- app/utils/rate-limit.ts | 6 +- app/utils/slug.ts | 2 +- app/utils/wakatime.ts | 2 +- next-auth.d.ts | 18 + package-lock.json | 2114 +++++++++++------ package.json | 12 +- .../20260728043534_init/migration.sql | 299 +++ prisma/migrations/migration_lock.toml | 3 + prisma/schema.prisma | 301 +++ .../20260320234600_add_user_stats.sql | 33 - .../20260320234643_add_top_user_stats.sql | 7 - .../20260320234653_add_profiles.sql | 43 - .../20260320234714_add_leaderboards.sql | 100 - .../20260320234731_add_enforcement_checks.sql | 61 - .../20260320234742_add_user_projects.sql | 23 - .../20260320234758_add_conversations.sql | 96 - ...40532_add_categories_to_top_user_stats.sql | 8 - ...260323033205_add_type_to_conversations.sql | 1 - .../20260323033729_add_global_chat.sql | 18 - ..._add_type_to_conversation_participants.sql | 6 - .../20260323041543_add_trigger_to_type.sql | 18 - ...0323075413_add_attachments_to_messages.sql | 21 - .../20260325044133_add_user_flexes.sql | 88 - ...60325054413_add_expires_at_user_flexes.sql | 11 - .../20260325072343_add_role_to_profiles.sql | 17 - ...0327100000_cascade_conversation_delete.sql | 10 - ...329120000_add_user_dashboard_snapshots.sql | 42 - ...00_add_chat_presence_and_read_tracking.sql | 14 - ...03000_enable_chat_realtime_publication.sql | 33 - ...60330104500_fix_global_chat_membership.sql | 45 - 155 files changed, 6266 insertions(+), 5488 deletions(-) delete mode 100644 .github/workflows/build.yml create mode 100644 app/api/admin/stats/route.ts create mode 100644 app/api/auth/[...nextauth]/route.ts delete mode 100644 app/api/auth/callback/route.ts create mode 100644 app/api/auth/forgot-password/route.ts create mode 100644 app/api/auth/register/route.ts create mode 100644 app/api/auth/reset-password/route.ts create mode 100644 app/api/auth/update-password/route.ts create mode 100644 app/api/conversations/[id]/presence/route.ts create mode 100644 app/api/conversations/[id]/route.ts create mode 100644 app/api/conversations/[id]/unread/route.ts create mode 100644 app/api/conversations/route.ts create mode 100644 app/api/cron/cleanup-messages/route.ts create mode 100644 app/api/flex/[id]/route.ts create mode 100644 app/api/flex/projects/route.ts create mode 100644 app/api/flex/route.ts create mode 100644 app/api/kanban/data/route.ts create mode 100644 app/api/kanban/issues/[id]/route.ts create mode 100644 app/api/kanban/issues/route.ts create mode 100644 app/api/leaderboards/[id]/join-code/route.ts create mode 100644 app/api/leaderboards/[id]/leave/route.ts create mode 100644 app/api/leaderboards/[id]/route.ts create mode 100644 app/api/leaderboards/join/route.ts create mode 100644 app/api/leaderboards/route.ts create mode 100644 app/api/messages/route.ts create mode 100644 app/api/presence/route.ts create mode 100644 app/api/profile/route.ts create mode 100644 app/api/sse/chat/[conversationId]/route.ts create mode 100644 app/api/sse/chat/[conversationId]/typing/route.ts create mode 100644 app/api/sse/conversations/route.ts create mode 100644 app/api/sse/kanban/route.ts create mode 100644 app/api/users/badges/route.ts create mode 100644 app/api/users/route.ts create mode 100644 app/lib/auth.ts create mode 100644 app/lib/auth/user.ts create mode 100644 app/lib/emitter.ts create mode 100644 app/lib/prisma.ts delete mode 100644 app/lib/supabase/client.ts delete mode 100644 app/lib/supabase/help/user.ts delete mode 100644 app/lib/supabase/public.ts delete mode 100644 app/lib/supabase/server.ts delete mode 100644 app/supabase-types.ts create mode 100644 next-auth.d.ts create mode 100644 prisma/migrations/20260728043534_init/migration.sql create mode 100644 prisma/migrations/migration_lock.toml create mode 100644 prisma/schema.prisma delete mode 100644 supabase/migrations/20260320234600_add_user_stats.sql delete mode 100644 supabase/migrations/20260320234643_add_top_user_stats.sql delete mode 100644 supabase/migrations/20260320234653_add_profiles.sql delete mode 100644 supabase/migrations/20260320234714_add_leaderboards.sql delete mode 100644 supabase/migrations/20260320234731_add_enforcement_checks.sql delete mode 100644 supabase/migrations/20260320234742_add_user_projects.sql delete mode 100644 supabase/migrations/20260320234758_add_conversations.sql delete mode 100644 supabase/migrations/20260321140532_add_categories_to_top_user_stats.sql delete mode 100644 supabase/migrations/20260323033205_add_type_to_conversations.sql delete mode 100644 supabase/migrations/20260323033729_add_global_chat.sql delete mode 100644 supabase/migrations/20260323041448_add_type_to_conversation_participants.sql delete mode 100644 supabase/migrations/20260323041543_add_trigger_to_type.sql delete mode 100644 supabase/migrations/20260323075413_add_attachments_to_messages.sql delete mode 100644 supabase/migrations/20260325044133_add_user_flexes.sql delete mode 100644 supabase/migrations/20260325054413_add_expires_at_user_flexes.sql delete mode 100644 supabase/migrations/20260325072343_add_role_to_profiles.sql delete mode 100644 supabase/migrations/20260327100000_cascade_conversation_delete.sql delete mode 100644 supabase/migrations/20260329120000_add_user_dashboard_snapshots.sql delete mode 100644 supabase/migrations/20260329133000_add_chat_presence_and_read_tracking.sql delete mode 100644 supabase/migrations/20260330103000_enable_chat_realtime_publication.sql delete mode 100644 supabase/migrations/20260330104500_fix_global_chat_membership.sql diff --git a/.env.example b/.env.example index 53de38e..abd2c54 100644 --- a/.env.example +++ b/.env.example @@ -1,13 +1,35 @@ NODE_ENV=development NEXT_PUBLIC_NODE_ENV=development -NEXT_PUBLIC_SUPABASE_BUCKET_NAME= -NEXT_PUBLIC_SUPABASE_URL= -NEXT_PUBLIC_SUPABASE_ANON_KEY= +# MySQL database +DATABASE_URL=mysql://user:password@localhost:3306/devpulse +# NextAuth +NEXTAUTH_SECRET= +NEXTAUTH_URL=http://localhost:3000 + +# OAuth providers (optional) +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= +GITHUB_CLIENT_ID= +GITHUB_CLIENT_SECRET= +AZURE_AD_CLIENT_ID= +AZURE_AD_CLIENT_SECRET= +AZURE_AD_TENANT_ID= + +# Email (for password reset) +EMAIL_SERVER=smtp://user:pass@smtp.example.com:587 +EMAIL_FROM=noreply@example.com + +# Global chat conversation ID (seed with a cuid, e.g. from `npx cuid`) +GLOBAL_CONVERSATION_ID= +NEXT_PUBLIC_GLOBAL_CONVERSATION_ID= + +# Cron job secret (protects POST /api/cron/cleanup-messages) +CRON_SECRET= + +# hCaptcha NEXT_PUBLIC_HCAPTCHA_SITE_KEY= +# Norton Safe Web NEXT_PUBLIC_NORTON_SAFEWEB_SITE_VERIFICATION= - -SUPABASE_ACCESS_TOKEN= -SUPABASE_PROJECT_ID=vswabkwgipyweqsabzwv diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml deleted file mode 100644 index 177bd56..0000000 --- a/.github/workflows/build.yml +++ /dev/null @@ -1,60 +0,0 @@ -name: Build Next.js App - -on: - push: - branches: ["master"] - pull_request_target: - branches: ["master"] - workflow_dispatch: - -env: - SUPABASE_PROJECT_ID: ${{ secrets.SUPABASE_PROJECT_ID }} - SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_ACCESS_TOKEN }} - NEXT_PUBLIC_SUPABASE_URL: ${{ secrets.NEXT_PUBLIC_SUPABASE_URL }} - NEXT_PUBLIC_SUPABASE_ANON_KEY: ${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }} - -jobs: - build: - runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Set up Node.js - uses: actions/setup-node@v4 - with: - node-version: 24 - - - name: Install dependencies - run: npm ci - - - name: Run lint - run: npm run lint - - # - name: Generate types from remote - # if: ${{ env.SUPABASE_PROJECT_ID && env.SUPABASE_ACCESS_TOKEN }} - # env: - # SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_ACCESS_TOKEN }} - # run: | - # npx supabase gen types typescript \ - # --project-id ${{ secrets.SUPABASE_PROJECT_ID }} \ - # > app/supabase-types.ts - - # this will be suspended for now - # - name: Run migrations - # env: - # SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_ACCESS_TOKEN }} - # run: | - # npx supabase db push --project-id ${{ secrets.SUPABASE_PROJECT_ID }} - - - name: Create .env file - if: ${{ env.NEXT_PUBLIC_SUPABASE_URL && env.NEXT_PUBLIC_SUPABASE_ANON_KEY }} - run: | - echo "NEXT_PUBLIC_SUPABASE_URL=${{ secrets.NEXT_PUBLIC_SUPABASE_URL }}" >> .env - echo "NEXT_PUBLIC_SUPABASE_ANON_KEY=${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }}" >> .env - echo "NEXT_PUBLIC_HCAPTCHA_SITE_KEY=${{ secrets.NEXT_PUBLIC_HCAPTCHA_SITE_KEY }}" >> .env - echo "NEXT_PUBLIC_NORTON_SAFEWEB_SITE_VERIFICATION=${{ secrets.NEXT_PUBLIC_NORTON_SAFEWEB_SITE_VERIFICATION }}" >> .env - - - name: Build project - if: ${{ env.NEXT_PUBLIC_SUPABASE_URL && env.NEXT_PUBLIC_SUPABASE_ANON_KEY }} - run: npm run build diff --git a/.gitignore b/.gitignore index 28e5999..bfcbba6 100644 --- a/.gitignore +++ b/.gitignore @@ -54,3 +54,5 @@ supabase/* yarn.lock pnpm-lock.yaml + +/app/generated/prisma diff --git a/app/(public)/flex/page.tsx b/app/(public)/flex/page.tsx index 1a0585c..4169a1c 100644 --- a/app/(public)/flex/page.tsx +++ b/app/(public)/flex/page.tsx @@ -6,7 +6,7 @@ import { timeAgo } from "@/app/utils/time"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faExternalLink } from "@fortawesome/free-solid-svg-icons"; import { Metadata } from "next/types"; -import { createPublicClient } from "@/app/lib/supabase/public"; +import { prisma } from "@/app/lib/prisma"; export const metadata: Metadata = { title: "Flexes - Devpulse", @@ -57,15 +57,10 @@ export const metadata: Metadata = { }; export default async function Flexs() { - const supabase = createPublicClient(); - const { data, error } = await supabase - .from("user_flexes") - .select("*") - .order("created_at", { ascending: false }); - - if (error) { - console.error("Error fetching flexes:", error); - } + const flexes = await prisma.userFlex.findMany({ + where: { expiresAt: { gt: new Date() } }, + orderBy: { createdAt: "desc" }, + }); return (
@@ -77,16 +72,7 @@ export default async function Flexs() {

Devpulse Flexes

- {error && ( -
-

Error Loading Flexes

-

- There was an error fetching the flexes. Please try again later. -

-
- )} - - {data?.length === 0 && ( + {flexes.length === 0 && (

No Flexes Yet

@@ -96,47 +82,49 @@ export default async function Flexs() {

)} - {data && data.length > 0 && ( + {flexes.length > 0 && (
- {data.map((flex) => ( + {flexes.map((flex) => (

- {flex.project_name} + {flex.projectName}

- {timeAgo(flex.created_at)} + + {timeAgo(flex.createdAt.toISOString())} +
- {flex.project_time} + {flex.projectTime}
Description: -

{flex.project_description}

- {flex.is_open_source && ( +

{flex.projectDescription}

+ {flex.isOpenSource && ( <> Open Source: - {flex.open_source_url} + {flex.openSourceUrl} )}

- Posted by {flex.user_email.split("@")[0]} + Posted by {flex.userEmail.split("@")[0]}

diff --git a/app/(public)/join/[code]/page.tsx b/app/(public)/join/[code]/page.tsx index c49cd21..87feb63 100644 --- a/app/(public)/join/[code]/page.tsx +++ b/app/(public)/join/[code]/page.tsx @@ -1,56 +1,38 @@ import { Metadata } from "next/types"; -import { createClient } from "../../../lib/supabase/server"; +import { prisma } from "@/app/lib/prisma"; import { redirect } from "next/navigation"; type Props = { params: Promise<{ code: string }>; }; -async function getLeaderboard(code: string) { - const supabase = await createClient(); - const { data } = await supabase - .from("leaderboards") - .select("id, name, description, slug, owner_id, created_at") - .eq("join_code", code) - .single(); - return data; -} - export async function generateMetadata({ params }: Props): Promise { const { code } = await params; - const leaderboard = await getLeaderboard(code); + + const leaderboard = await prisma.leaderboard.findUnique({ + where: { joinCode: code }, + select: { name: true, description: true }, + }); if (!leaderboard) { return { title: "Invite Not Found - Devpulse", description: "This invite link is invalid or has expired.", - alternates: { - canonical: `https://devpulse.hallofcodes.org/join`, - }, + alternates: { canonical: "https://devpulse.hallofcodes.org/join" }, }; } const title = `You're invited to join ${leaderboard.name}!`; const description = - leaderboard?.description && leaderboard.description.length > 0 + leaderboard.description && leaderboard.description.length > 0 ? leaderboard.description - : `Join the ${leaderboard.name} leaderboard on Devpulse and compete with other developers. Track your coding activity and climb the ranks!`; + : `Join the ${leaderboard.name} leaderboard on Devpulse and compete with other developers.`; return { title: `${title} - Devpulse`, description, - openGraph: { - title, - description, - type: "website", - siteName: "Devpulse", - url: `/join?id=${encodeURIComponent(code)}`, - }, - twitter: { - card: "summary_large_image", - title, - description, - }, + openGraph: { title, description, type: "website", siteName: "Devpulse" }, + twitter: { card: "summary_large_image", title, description }, }; } diff --git a/app/(public)/join/page.tsx b/app/(public)/join/page.tsx index f52f958..5d6a41f 100644 --- a/app/(public)/join/page.tsx +++ b/app/(public)/join/page.tsx @@ -1,5 +1,6 @@ import { Metadata } from "next/types"; -import { createClient } from "../../lib/supabase/server"; +import { prisma } from "@/app/lib/prisma"; +import { getCurrentUser } from "@/app/lib/auth/user"; import JoinButton from "../../components/JoinButton"; import Footer from "@/app/components/layout/Footer"; import Image from "next/image"; @@ -17,22 +18,21 @@ type Props = { }; async function getLeaderboard(code: string) { - const supabase = await createClient(); - const { data } = await supabase - .from("leaderboards") - .select("id, name, description, slug, owner_id, created_at") - .eq("join_code", code) - .single(); - return data; + return prisma.leaderboard.findUnique({ + where: { joinCode: code }, + select: { + id: true, + name: true, + description: true, + slug: true, + ownerId: true, + createdAt: true, + }, + }); } async function getMemberCount(leaderboardId: string) { - const supabase = await createClient(); - const { count } = await supabase - .from("leaderboard_members_view") - .select("*", { count: "exact", head: true }) - .eq("leaderboard_id", leaderboardId); - return count ?? 0; + return prisma.leaderboardMember.count({ where: { leaderboardId } }); } export async function generateMetadata({ @@ -45,9 +45,7 @@ export async function generateMetadata({ return { title: "Join - Devpulse", description: "Open an invite link to join a Devpulse leaderboard.", - alternates: { - canonical: "https://devpulse.hallofcodes.org/join", - }, + alternates: { canonical: "https://devpulse.hallofcodes.org/join" }, }; } @@ -56,17 +54,15 @@ export async function generateMetadata({ return { title: "Invite Not Found - Devpulse", description: "This invite link is invalid or has expired.", - alternates: { - canonical: "https://devpulse.hallofcodes.org/join", - }, + alternates: { canonical: "https://devpulse.hallofcodes.org/join" }, }; } const title = `You're invited to join ${leaderboard.name}!`; const description = - leaderboard.description && leaderboard.description?.length > 0 + leaderboard.description && leaderboard.description.length > 0 ? leaderboard.description - : `Join the ${leaderboard.name} leaderboard on Devpulse and compete with other developers. Track your coding activity and climb the ranks!`; + : `Join the ${leaderboard.name} leaderboard on Devpulse and compete with other developers.`; return { title: `${title} - Devpulse`, @@ -74,18 +70,8 @@ export async function generateMetadata({ alternates: { canonical: `https://devpulse.hallofcodes.org/join?id=${encodeURIComponent(code)}`, }, - openGraph: { - title, - description, - type: "website", - siteName: "Devpulse", - url: `https://devpulse.hallofcodes.org/join?id=${encodeURIComponent(code)}`, - }, - twitter: { - card: "summary_large_image", - title, - description, - }, + openGraph: { title, description, type: "website", siteName: "Devpulse" }, + twitter: { card: "summary_large_image", title, description }, }; } @@ -118,7 +104,10 @@ export default async function JoinPage({ searchParams }: Props) { ); } - const leaderboard = await getLeaderboard(code); + const [leaderboard, { user }] = await Promise.all([ + getLeaderboard(code), + getCurrentUser(), + ]); if (!leaderboard) { return ( @@ -146,19 +135,17 @@ export default async function JoinPage({ searchParams }: Props) { const memberCount = await getMemberCount(leaderboard.id); - const supabase = await createClient(); - const { - data: { user }, - } = await supabase.auth.getUser(); - let alreadyMember = false; if (user) { - const { data: membership } = await supabase - .from("leaderboard_members") - .select("id") - .eq("leaderboard_id", leaderboard.id) - .eq("user_id", user.id) - .single(); + const membership = await prisma.leaderboardMember.findUnique({ + where: { + leaderboardId_userId: { + leaderboardId: leaderboard.id, + userId: user.id, + }, + }, + select: { id: true }, + }); alreadyMember = !!membership; } @@ -177,8 +164,8 @@ export default async function JoinPage({ searchParams }: Props) {

{alreadyMember - ? "You\u2019re already a member of" - : "You\u2019ve been invited to"} + ? "You’re already a member of" + : "You’ve been invited to"}

diff --git a/app/(public)/leaderboard/[slug]/page.tsx b/app/(public)/leaderboard/[slug]/page.tsx index 5bbcb42..158d791 100644 --- a/app/(public)/leaderboard/[slug]/page.tsx +++ b/app/(public)/leaderboard/[slug]/page.tsx @@ -8,42 +8,64 @@ import Banner from "@/app/components/leaderboard/Banner"; import BackButton from "@/app/components/leaderboard/BackButton"; import Image from "next/image"; import InviteFriendsButton from "@/app/components/leaderboard/InviteFriendsButton"; -import { createPublicClient } from "@/app/lib/supabase/public"; import InternalServerError from "@/app/internal-server-error"; +import { prisma } from "@/app/lib/prisma"; export async function generateStaticParams() { - const supabase = createPublicClient(); + const leaderboards = await prisma.leaderboard.findMany({ + select: { slug: true }, + }); - const { data } = await supabase.from("leaderboards").select("slug"); - - return (data || []).map((item) => ({ - slug: item.slug, - })); + return leaderboards.map((item) => ({ slug: item.slug })); } export default async function LeaderboardPage(props: { params: Promise<{ slug: string }>; }) { const { slug } = await props.params; - const supabase = createPublicClient(); - const { data: leaderboard, error: leaderboardError } = await supabase - .from("leaderboards") - .select("*") - .eq("slug", slug) - .single(); + const leaderboard = await prisma.leaderboard.findUnique({ + where: { slug }, + }); + if (!leaderboard) return notFound(); - if (leaderboardError) { - console.error("Error fetching leaderboard:", leaderboardError); - return InternalServerError(); - } - const { data: members, error: membersError } = await supabase - .from("leaderboard_members_view") - .select("*") - .eq("leaderboard_id", leaderboard.id); - if (membersError) { - console.error("Error fetching members:", membersError); + let members: NonNullableMember[] = []; + try { + const rows = await prisma.leaderboardMember.findMany({ + where: { leaderboardId: leaderboard.id }, + include: { + user: { + select: { + id: true, + email: true, + role: true, + userStats: { + select: { + totalSeconds: true, + languages: true, + operatingSystems: true, + editors: true, + }, + }, + }, + }, + }, + }); + + members = rows + .filter((r) => r.user.email) + .map((r) => ({ + user_id: r.userId, + role: r.role, + email: r.user.email!, + total_seconds: Number(r.user.userStats?.totalSeconds ?? 0), + languages: (r.user.userStats?.languages as { name: string }[]) ?? [], + operating_systems: + (r.user.userStats?.operatingSystems as { name: string }[]) ?? [], + editors: (r.user.userStats?.editors as { name: string }[]) ?? [], + })); + } catch { return InternalServerError(); } @@ -81,8 +103,7 @@ export default async function LeaderboardPage(props: { {leaderboard.name}

- {leaderboard.description && - leaderboard.description?.length > 0 + {leaderboard.description && leaderboard.description.length > 0 ? leaderboard.description : `Join ${leaderboard.name} to track your coding metrics, compete with fellow developers, and showcase your engineering skills.`}

@@ -91,7 +112,7 @@ export default async function LeaderboardPage(props: {
@@ -99,7 +120,7 @@ export default async function LeaderboardPage(props: {
- +
diff --git a/app/(public)/leaderboard/page.tsx b/app/(public)/leaderboard/page.tsx index a0c0cf6..ba18d22 100644 --- a/app/(public)/leaderboard/page.tsx +++ b/app/(public)/leaderboard/page.tsx @@ -3,10 +3,9 @@ import Footer from "@/app/components/layout/Footer"; import CTA from "@/app/components/common/ui/CTA"; import Image from "next/image"; import { Metadata } from "next/types"; -import { getUserWithProfile } from "@/app/lib/supabase/help/user"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faArrowRight } from "@fortawesome/free-solid-svg-icons"; -import { createPublicClient } from "@/app/lib/supabase/public"; +import { prisma } from "@/app/lib/prisma"; export const metadata: Metadata = { title: "Leaderboards - Devpulse", @@ -58,15 +57,10 @@ export const metadata: Metadata = { }; export default async function Leaderboards() { - const supabase = createPublicClient(); - const { data, error } = await supabase - .from("leaderboards") - .select("id, name, slug") - .order("created_at", { ascending: false }); - - if (error) { - console.error("Error fetching leaderboards:", error); - } + const leaderboards = await prisma.leaderboard.findMany({ + select: { id: true, name: true, slug: true }, + orderBy: { createdAt: "desc" }, + }); return (
@@ -80,19 +74,7 @@ export default async function Leaderboards() {
- {error && ( -
-

- Error Loading Leaderboards -

-

- There was an error fetching the leaderboards. Please try again - later. -

-
- )} - - {data?.length === 0 && ( + {leaderboards.length === 0 && (

No Leaderboards Yet

@@ -101,33 +83,28 @@ export default async function Leaderboards() {

)} - {data && data.length > 0 && ( + {leaderboards.length > 0 && (
- {data.map( - ( - board: { id: string; name: string; slug: string }, - i: number, - ) => ( - -
-
- - {board.name} - -
- - View{" "} - + {leaderboards.map((board, i) => ( +
+
+ + + View{" "} + + + + ))}
)}
diff --git a/app/api/admin/stats/route.ts b/app/api/admin/stats/route.ts new file mode 100644 index 0000000..ef4be2e --- /dev/null +++ b/app/api/admin/stats/route.ts @@ -0,0 +1,50 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/app/lib/auth"; +import { prisma } from "@/app/lib/prisma"; + +export async function GET() { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const user = await prisma.user.findUnique({ + where: { id: session.user.id }, + select: { role: true }, + }); + + if (user?.role !== "admin") { + return NextResponse.json({ error: "Forbidden." }, { status: 403 }); + } + + const [topUserStats, threads, messages, leaderboards, flexes] = + await Promise.all([ + prisma.userStats.findMany({ + select: { + userId: true, + totalSeconds: true, + categories: true, + user: { select: { email: true } }, + }, + }), + prisma.conversation.count(), + prisma.message.count({ where: { expiresAt: { gt: new Date() } } }), + prisma.leaderboard.count(), + prisma.userFlex.count({ where: { expiresAt: { gt: new Date() } } }), + ]); + + const users = topUserStats.map((row) => ({ + user_id: row.userId, + email: row.user.email, + total_seconds: Number(row.totalSeconds), + categories: row.categories, + })); + + return NextResponse.json({ + users, + totalThreads: threads, + totalMessages: messages, + totalLeaderboards: leaderboards, + totalFlexes: flexes, + }); +} diff --git a/app/api/auth/[...nextauth]/route.ts b/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000..0c27937 --- /dev/null +++ b/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,3 @@ +import { handlers } from "@/app/lib/auth"; + +export const { GET, POST } = handlers; diff --git a/app/api/auth/callback/route.ts b/app/api/auth/callback/route.ts deleted file mode 100644 index f245a1b..0000000 --- a/app/api/auth/callback/route.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { createServerClient } from "@supabase/ssr"; - -export async function GET(req: NextRequest) { - const { searchParams, origin } = new URL(req.url); - const code = searchParams.get("code"); - const cookieRedirect = req.cookies.get("devpulse_redirect")?.value; - const redirectParam = cookieRedirect ? decodeURIComponent(cookieRedirect) : null; - const redirectTo = - redirectParam && redirectParam.startsWith("/") && !redirectParam.startsWith("//") - ? redirectParam - : "/d"; - - const response = NextResponse.redirect(`${origin}${redirectTo}`); - response.cookies.set("devpulse_redirect", "", { path: "/", maxAge: 0 }); - if (!code) return response; - - const supabase = createServerClient( - process.env.NEXT_PUBLIC_SUPABASE_URL!, - process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, - { - cookies: { - getAll() { - return req.cookies.getAll(); - }, - setAll(cookies) { - cookies.forEach(({ name, value, options }) => { - response.cookies.set(name, value, options); - }); - }, - }, - }, - ); - - const { error } = await supabase.auth.exchangeCodeForSession(code); - - if (error) - return NextResponse.redirect( - `${origin}/login?error=oauth_failed&redirect=${encodeURIComponent(redirectTo)}`, - ); - - return response; -} diff --git a/app/api/auth/forgot-password/route.ts b/app/api/auth/forgot-password/route.ts new file mode 100644 index 0000000..3176eae --- /dev/null +++ b/app/api/auth/forgot-password/route.ts @@ -0,0 +1,29 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@/app/lib/prisma"; + +export async function POST(req: Request) { + const { email } = await req.json(); + + if (!email) { + return NextResponse.json({ error: "Email is required." }, { status: 400 }); + } + + const user = await prisma.user.findUnique({ where: { email } }); + + if (!user) { + return NextResponse.json({ success: true }); + } + + const expiresAt = new Date(Date.now() + 60 * 60 * 1000); + + const { token } = await prisma.passwordResetToken.create({ + data: { userId: user.id, expiresAt }, + select: { token: true }, + }); + + const resetUrl = `${process.env.NEXTAUTH_URL}/reset-password?token=${token}`; + + console.info(`Password reset link for ${email}: ${resetUrl}`); + + return NextResponse.json({ success: true }); +} diff --git a/app/api/auth/register/route.ts b/app/api/auth/register/route.ts new file mode 100644 index 0000000..8612262 --- /dev/null +++ b/app/api/auth/register/route.ts @@ -0,0 +1,41 @@ +import { NextResponse } from "next/server"; +import bcrypt from "bcryptjs"; +import { prisma } from "@/app/lib/prisma"; + +export async function POST(req: Request) { + const { email, password } = await req.json(); + + if (!email || !password) { + return NextResponse.json( + { error: "Email and password are required." }, + { status: 400 }, + ); + } + + if (password.length < 8) { + return NextResponse.json( + { error: "Password must be at least 8 characters." }, + { status: 400 }, + ); + } + + const existing = await prisma.user.findUnique({ where: { email } }); + if (existing) { + return NextResponse.json( + { error: "An account with this email already exists." }, + { status: 409 }, + ); + } + + const hashed = await bcrypt.hash(password, 12); + + await prisma.user.create({ + data: { + email, + password: hashed, + name: email.split("@")[0], + }, + }); + + return NextResponse.json({ success: true }, { status: 201 }); +} diff --git a/app/api/auth/reset-password/route.ts b/app/api/auth/reset-password/route.ts new file mode 100644 index 0000000..4a8cae7 --- /dev/null +++ b/app/api/auth/reset-password/route.ts @@ -0,0 +1,44 @@ +import { NextResponse } from "next/server"; +import bcrypt from "bcryptjs"; +import { prisma } from "@/app/lib/prisma"; + +export async function POST(req: Request) { + const { token, password } = await req.json(); + + if (!token || !password) { + return NextResponse.json( + { error: "Token and password are required." }, + { status: 400 }, + ); + } + + if (password.length < 8) { + return NextResponse.json( + { error: "Password must be at least 8 characters." }, + { status: 400 }, + ); + } + + const resetToken = await prisma.passwordResetToken.findUnique({ + where: { token }, + }); + + if (!resetToken || resetToken.expiresAt < new Date()) { + return NextResponse.json( + { error: "Invalid or expired reset link." }, + { status: 400 }, + ); + } + + const hashed = await bcrypt.hash(password, 12); + + await prisma.$transaction([ + prisma.user.update({ + where: { id: resetToken.userId }, + data: { password: hashed }, + }), + prisma.passwordResetToken.delete({ where: { token } }), + ]); + + return NextResponse.json({ success: true }); +} diff --git a/app/api/auth/update-password/route.ts b/app/api/auth/update-password/route.ts new file mode 100644 index 0000000..186b44f --- /dev/null +++ b/app/api/auth/update-password/route.ts @@ -0,0 +1,29 @@ +import { NextResponse } from "next/server"; +import bcrypt from "bcryptjs"; +import { auth } from "@/app/lib/auth"; +import { prisma } from "@/app/lib/prisma"; + +export async function POST(req: Request) { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized." }, { status: 401 }); + } + + const { password } = await req.json(); + + if (!password || password.length < 8) { + return NextResponse.json( + { error: "Password must be at least 8 characters." }, + { status: 400 }, + ); + } + + const hashed = await bcrypt.hash(password, 12); + + await prisma.user.update({ + where: { id: session.user.id }, + data: { password: hashed }, + }); + + return NextResponse.json({ success: true }); +} diff --git a/app/api/conversations/[id]/presence/route.ts b/app/api/conversations/[id]/presence/route.ts new file mode 100644 index 0000000..f6cf3ea --- /dev/null +++ b/app/api/conversations/[id]/presence/route.ts @@ -0,0 +1,33 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/app/lib/auth"; +import { prisma } from "@/app/lib/prisma"; + +export async function PATCH( + req: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { id: conversationId } = await params; + const body = await req.json().catch(() => ({})); + const { markRead } = body as { markRead?: boolean }; + + const timestamp = new Date(); + + const data: { lastSeenAt: Date; lastReadAt?: Date } = { + lastSeenAt: timestamp, + }; + if (markRead) { + data.lastReadAt = timestamp; + } + + await prisma.conversationParticipant.updateMany({ + where: { conversationId, userId: session.user.id }, + data, + }); + + return NextResponse.json({ success: true }); +} diff --git a/app/api/conversations/[id]/route.ts b/app/api/conversations/[id]/route.ts new file mode 100644 index 0000000..6be2a41 --- /dev/null +++ b/app/api/conversations/[id]/route.ts @@ -0,0 +1,29 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/app/lib/auth"; +import { prisma } from "@/app/lib/prisma"; + +export async function DELETE( + _req: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { id } = await params; + + const participant = await prisma.conversationParticipant.findUnique({ + where: { + conversationId_userId: { conversationId: id, userId: session.user.id }, + }, + }); + + if (!participant) { + return NextResponse.json({ error: "Not found." }, { status: 404 }); + } + + await prisma.conversation.delete({ where: { id } }); + + return NextResponse.json({ success: true }); +} diff --git a/app/api/conversations/[id]/unread/route.ts b/app/api/conversations/[id]/unread/route.ts new file mode 100644 index 0000000..d83353f --- /dev/null +++ b/app/api/conversations/[id]/unread/route.ts @@ -0,0 +1,37 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/app/lib/auth"; +import { prisma } from "@/app/lib/prisma"; + +export async function GET( + _req: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { id: conversationId } = await params; + + const participant = await prisma.conversationParticipant.findUnique({ + where: { + conversationId_userId: { conversationId, userId: session.user.id }, + }, + select: { lastReadAt: true }, + }); + + if (!participant) { + return NextResponse.json({ count: 0 }); + } + + const count = await prisma.message.count({ + where: { + conversationId, + senderId: { not: session.user.id }, + createdAt: { gt: participant.lastReadAt }, + expiresAt: { gt: new Date() }, + }, + }); + + return NextResponse.json({ count }); +} diff --git a/app/api/conversations/route.ts b/app/api/conversations/route.ts new file mode 100644 index 0000000..88d929b --- /dev/null +++ b/app/api/conversations/route.ts @@ -0,0 +1,103 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/app/lib/auth"; +import { prisma } from "@/app/lib/prisma"; + +export async function GET() { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const participantRows = await prisma.conversationParticipant.findMany({ + where: { userId: session.user.id }, + include: { + conversation: { + include: { + participants: { + select: { + userId: true, + email: true, + lastSeenAt: true, + lastReadAt: true, + }, + }, + }, + }, + }, + }); + + const conversations = participantRows.map((row) => ({ + id: row.conversationId, + type: row.conversation.type.toLowerCase(), + created_at: row.conversation.createdAt.toISOString(), + last_read_at: row.lastReadAt.toISOString(), + users: row.conversation.participants.map((p) => ({ + id: p.userId, + email: p.email, + last_seen_at: p.lastSeenAt.toISOString(), + })), + })); + + return NextResponse.json(conversations); +} + +export async function POST(req: Request) { + const session = await auth(); + if (!session?.user?.id || !session.user.email) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { otherUserId, otherUserEmail } = await req.json(); + + if (!otherUserId) { + return NextResponse.json( + { error: "otherUserId is required." }, + { status: 400 }, + ); + } + + const timestamp = new Date(); + const EPOCH = new Date("1970-01-01T00:00:00.000Z"); + + const conversation = await prisma.conversation.create({ + data: { + type: "PRIVATE", + participants: { + create: [ + { + userId: session.user.id, + email: session.user.email, + lastSeenAt: timestamp, + lastReadAt: timestamp, + }, + { + userId: otherUserId, + email: otherUserEmail ?? "", + lastSeenAt: EPOCH, + lastReadAt: EPOCH, + }, + ], + }, + }, + include: { + participants: { + select: { userId: true, email: true, lastSeenAt: true }, + }, + }, + }); + + return NextResponse.json( + { + id: conversation.id, + type: "private", + created_at: conversation.createdAt.toISOString(), + last_read_at: timestamp.toISOString(), + users: conversation.participants.map((p) => ({ + id: p.userId, + email: p.email, + last_seen_at: p.lastSeenAt.toISOString(), + })), + }, + { status: 201 }, + ); +} diff --git a/app/api/cron/cleanup-messages/route.ts b/app/api/cron/cleanup-messages/route.ts new file mode 100644 index 0000000..0399665 --- /dev/null +++ b/app/api/cron/cleanup-messages/route.ts @@ -0,0 +1,15 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@/app/lib/prisma"; + +export async function POST(req: Request) { + const secret = req.headers.get("x-cron-secret"); + if (!secret || secret !== process.env.CRON_SECRET) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { count } = await prisma.message.deleteMany({ + where: { expiresAt: { lt: new Date() } }, + }); + + return NextResponse.json({ deleted: count }); +} diff --git a/app/api/flex/[id]/route.ts b/app/api/flex/[id]/route.ts new file mode 100644 index 0000000..591a808 --- /dev/null +++ b/app/api/flex/[id]/route.ts @@ -0,0 +1,61 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/app/lib/auth"; +import { prisma } from "@/app/lib/prisma"; + +export async function PUT( + req: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { id } = await params; + const body = await req.json(); + const { + project_name, + project_description, + project_url, + project_time, + is_open_source, + open_source_url, + } = body; + + const flex = await prisma.userFlex.updateMany({ + where: { id, userId: session.user.id }, + data: { + projectName: project_name?.trim(), + projectDescription: project_description ?? "", + projectUrl: project_url ?? "", + projectTime: project_time ?? "", + isOpenSource: is_open_source ?? false, + openSourceUrl: is_open_source ? (open_source_url ?? "") : "", + }, + }); + + if (flex.count === 0) { + return NextResponse.json({ error: "Not found." }, { status: 404 }); + } + + const updated = await prisma.userFlex.findUnique({ where: { id } }); + return NextResponse.json(updated); +} + +export async function DELETE( + _req: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { id } = await params; + + await prisma.userFlex.deleteMany({ + where: { id, userId: session.user.id }, + }); + + return NextResponse.json({ success: true }); +} diff --git a/app/api/flex/projects/route.ts b/app/api/flex/projects/route.ts new file mode 100644 index 0000000..92efc62 --- /dev/null +++ b/app/api/flex/projects/route.ts @@ -0,0 +1,17 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/app/lib/auth"; +import { prisma } from "@/app/lib/prisma"; + +export async function GET() { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const userProjects = await prisma.userProjects.findUnique({ + where: { userId: session.user.id }, + select: { projects: true }, + }); + + return NextResponse.json({ projects: userProjects?.projects ?? [] }); +} diff --git a/app/api/flex/route.ts b/app/api/flex/route.ts new file mode 100644 index 0000000..2ad4644 --- /dev/null +++ b/app/api/flex/route.ts @@ -0,0 +1,59 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/app/lib/auth"; +import { prisma } from "@/app/lib/prisma"; + +export async function GET() { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const flexes = await prisma.userFlex.findMany({ + where: { userId: session.user.id }, + orderBy: { createdAt: "desc" }, + }); + + return NextResponse.json(flexes); +} + +export async function POST(req: Request) { + const session = await auth(); + if (!session?.user?.id || !session.user.email) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const body = await req.json(); + const { + project_name, + project_description, + project_url, + project_time, + is_open_source, + open_source_url, + } = body; + + if (!project_name?.trim()) { + return NextResponse.json( + { error: "Project name is required." }, + { status: 400 }, + ); + } + + const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); + + const flex = await prisma.userFlex.create({ + data: { + userId: session.user.id, + userEmail: session.user.email, + projectName: project_name.trim(), + projectDescription: project_description ?? "", + projectUrl: project_url ?? "", + projectTime: project_time ?? "", + isOpenSource: is_open_source ?? false, + openSourceUrl: is_open_source ? (open_source_url ?? "") : "", + expiresAt, + }, + }); + + return NextResponse.json(flex, { status: 201 }); +} diff --git a/app/api/kanban/data/route.ts b/app/api/kanban/data/route.ts new file mode 100644 index 0000000..ae2bd3f --- /dev/null +++ b/app/api/kanban/data/route.ts @@ -0,0 +1,43 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/app/lib/auth"; +import { prisma } from "@/app/lib/prisma"; + +export async function GET() { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const [projects, boards, columns, issues] = await Promise.all([ + prisma.project.findMany({ orderBy: { createdAt: "asc" } }), + prisma.board.findMany({ orderBy: { createdAt: "asc" } }), + prisma.column.findMany({ orderBy: { position: "asc" } }), + prisma.issue.findMany({ orderBy: { position: "asc" } }), + ]); + + return NextResponse.json({ + projects: projects.map((p) => ({ id: p.id, name: p.name })), + boards: boards.map((b) => ({ + id: b.id, + project_id: b.projectId, + title: b.title, + description: b.description ?? "", + })), + columns: columns.map((c) => ({ + id: c.id, + board_id: c.boardId, + title: c.title, + position: c.position, + })), + issues: issues.map((i) => ({ + id: i.id, + column_id: i.columnId, + issue_key: i.issueKey, + title: i.title, + tag: i.tag ?? "", + type: i.type.toLowerCase(), + priority: i.priority.toLowerCase(), + position: i.position, + })), + }); +} diff --git a/app/api/kanban/issues/[id]/route.ts b/app/api/kanban/issues/[id]/route.ts new file mode 100644 index 0000000..da2503f --- /dev/null +++ b/app/api/kanban/issues/[id]/route.ts @@ -0,0 +1,46 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/app/lib/auth"; +import { prisma } from "@/app/lib/prisma"; +import { emitter } from "@/app/lib/emitter"; + +export async function PATCH( + req: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { id } = await params; + const body = await req.json(); + const { column_id, position } = body as { + column_id?: string; + position?: number; + }; + + const data: Record = {}; + if (column_id !== undefined) data.columnId = column_id; + if (position !== undefined) data.position = position; + + if (Object.keys(data).length === 0) { + return NextResponse.json({ error: "Nothing to update." }, { status: 400 }); + } + + const issue = await prisma.issue.update({ + where: { id }, + data: { ...data, updatedAt: new Date() }, + }); + + const payload = { + type: "issue_updated", + data: { id: issue.id, column_id: issue.columnId, position: issue.position }, + }; + emitter.emit("kanban", payload); + + return NextResponse.json({ + id: issue.id, + column_id: issue.columnId, + position: issue.position, + }); +} diff --git a/app/api/kanban/issues/route.ts b/app/api/kanban/issues/route.ts new file mode 100644 index 0000000..444c178 --- /dev/null +++ b/app/api/kanban/issues/route.ts @@ -0,0 +1,61 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/app/lib/auth"; +import { prisma } from "@/app/lib/prisma"; +import { emitter } from "@/app/lib/emitter"; +import { IssueType, IssuePriority } from "@prisma/client"; + +const TYPE_MAP: Record = { + bug: "BUG", + feature: "FEATURE", + chore: "CHORE", +}; + +const PRIORITY_MAP: Record = { + p0: "P0", + p1: "P1", + p2: "P2", +}; + +export async function POST(req: Request) { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { columnId, title, tag, type, priority, issueKey, position } = + await req.json(); + + if (!columnId || !title?.trim() || !issueKey) { + return NextResponse.json( + { error: "columnId, title, and issueKey are required." }, + { status: 400 }, + ); + } + + const issue = await prisma.issue.create({ + data: { + columnId, + issueKey, + title: title.trim(), + tag: tag ?? "", + type: TYPE_MAP[type] ?? "FEATURE", + priority: PRIORITY_MAP[priority] ?? "P2", + position: position ?? 0, + }, + }); + + const payload = { + id: issue.id, + column_id: issue.columnId, + issue_key: issue.issueKey, + title: issue.title, + tag: issue.tag ?? "", + type: issue.type.toLowerCase(), + priority: issue.priority.toLowerCase(), + position: issue.position, + }; + + emitter.emit("kanban", { type: "issue_created", data: payload }); + + return NextResponse.json(payload, { status: 201 }); +} diff --git a/app/api/leaderboards/[id]/join-code/route.ts b/app/api/leaderboards/[id]/join-code/route.ts new file mode 100644 index 0000000..696cf11 --- /dev/null +++ b/app/api/leaderboards/[id]/join-code/route.ts @@ -0,0 +1,56 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/app/lib/auth"; +import { prisma } from "@/app/lib/prisma"; + +export async function GET( + _req: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { id } = await params; + + const leaderboard = await prisma.leaderboard.findUnique({ + where: { id }, + select: { joinCode: true, ownerId: true }, + }); + + if (!leaderboard || leaderboard.ownerId !== session.user.id) { + return NextResponse.json({ error: "Forbidden." }, { status: 403 }); + } + + return NextResponse.json({ joinCode: leaderboard.joinCode }); +} + +export async function PATCH( + _req: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { id } = await params; + + const leaderboard = await prisma.leaderboard.findUnique({ + where: { id }, + select: { ownerId: true }, + }); + + if (!leaderboard || leaderboard.ownerId !== session.user.id) { + return NextResponse.json({ error: "Forbidden." }, { status: 403 }); + } + + const joinCode = crypto.randomUUID().slice(0, 8); + + await prisma.leaderboard.update({ + where: { id }, + data: { joinCode }, + }); + + return NextResponse.json({ success: true, joinCode }); +} diff --git a/app/api/leaderboards/[id]/leave/route.ts b/app/api/leaderboards/[id]/leave/route.ts new file mode 100644 index 0000000..ec52d9b --- /dev/null +++ b/app/api/leaderboards/[id]/leave/route.ts @@ -0,0 +1,21 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/app/lib/auth"; +import { prisma } from "@/app/lib/prisma"; + +export async function DELETE( + _req: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { id } = await params; + + await prisma.leaderboardMember.deleteMany({ + where: { leaderboardId: id, userId: session.user.id }, + }); + + return NextResponse.json({ success: true }); +} diff --git a/app/api/leaderboards/[id]/route.ts b/app/api/leaderboards/[id]/route.ts new file mode 100644 index 0000000..1d2134f --- /dev/null +++ b/app/api/leaderboards/[id]/route.ts @@ -0,0 +1,32 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/app/lib/auth"; +import { prisma } from "@/app/lib/prisma"; + +export async function DELETE( + _req: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { id } = await params; + + const leaderboard = await prisma.leaderboard.findUnique({ + where: { id }, + select: { ownerId: true }, + }); + + if (!leaderboard) { + return NextResponse.json({ error: "Not found." }, { status: 404 }); + } + + if (leaderboard.ownerId !== session.user.id) { + return NextResponse.json({ error: "Forbidden." }, { status: 403 }); + } + + await prisma.leaderboard.delete({ where: { id } }); + + return NextResponse.json({ success: true }); +} diff --git a/app/api/leaderboards/join/route.ts b/app/api/leaderboards/join/route.ts new file mode 100644 index 0000000..38d2656 --- /dev/null +++ b/app/api/leaderboards/join/route.ts @@ -0,0 +1,47 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/app/lib/auth"; +import { prisma } from "@/app/lib/prisma"; + +export async function POST(req: Request) { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { joinCode } = await req.json(); + if (!joinCode) { + return NextResponse.json( + { error: "Join code is required." }, + { status: 400 }, + ); + } + + const leaderboard = await prisma.leaderboard.findUnique({ + where: { joinCode }, + select: { id: true, slug: true }, + }); + + if (!leaderboard) { + return NextResponse.json( + { error: "Invalid invite code." }, + { status: 404 }, + ); + } + + try { + await prisma.leaderboardMember.create({ + data: { + leaderboardId: leaderboard.id, + userId: session.user.id, + role: "member", + }, + }); + } catch { + return NextResponse.json( + { error: "You are already a member of this leaderboard." }, + { status: 409 }, + ); + } + + return NextResponse.json({ success: true, slug: leaderboard.slug }); +} diff --git a/app/api/leaderboards/route.ts b/app/api/leaderboards/route.ts new file mode 100644 index 0000000..73b5999 --- /dev/null +++ b/app/api/leaderboards/route.ts @@ -0,0 +1,50 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/app/lib/auth"; +import { prisma } from "@/app/lib/prisma"; +import { toKebabSlug } from "@/app/utils/slug"; + +export async function POST(req: Request) { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { name } = await req.json(); + if (!name?.trim()) { + return NextResponse.json({ error: "Name is required." }, { status: 400 }); + } + + const joinCode = crypto.randomUUID().slice(0, 8); + const slug = toKebabSlug(name.trim(), "leaderboard"); + + try { + const leaderboard = await prisma.leaderboard.create({ + data: { + name: name.trim(), + description: "", + slug, + ownerId: session.user.id, + joinCode, + isPublic: true, + }, + }); + + await prisma.leaderboardMember.create({ + data: { + leaderboardId: leaderboard.id, + userId: session.user.id, + role: "owner", + }, + }); + + return NextResponse.json( + { joinCode: leaderboard.joinCode }, + { status: 201 }, + ); + } catch { + return NextResponse.json( + { error: "A leaderboard with that name already exists." }, + { status: 409 }, + ); + } +} diff --git a/app/api/messages/route.ts b/app/api/messages/route.ts new file mode 100644 index 0000000..e60f3d6 --- /dev/null +++ b/app/api/messages/route.ts @@ -0,0 +1,113 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/app/lib/auth"; +import { prisma } from "@/app/lib/prisma"; +import { emitter } from "@/app/lib/emitter"; + +export async function GET(req: Request) { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { searchParams } = new URL(req.url); + const conversationId = searchParams.get("conversationId"); + + if (!conversationId) { + return NextResponse.json( + { error: "conversationId is required." }, + { status: 400 }, + ); + } + + const participant = await prisma.conversationParticipant.findUnique({ + where: { + conversationId_userId: { conversationId, userId: session.user.id }, + }, + }); + + if (!participant) { + return NextResponse.json({ error: "Forbidden." }, { status: 403 }); + } + + const messages = await prisma.message.findMany({ + where: { + conversationId, + expiresAt: { gt: new Date() }, + }, + orderBy: { createdAt: "asc" }, + }); + + return NextResponse.json( + messages.map((m) => ({ + id: m.id, + conversation_id: m.conversationId, + sender_id: m.senderId, + text: m.text, + attachments: m.attachments, + created_at: m.createdAt.toISOString(), + })), + ); +} + +export async function POST(req: Request) { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { conversationId, text, attachments } = await req.json(); + + if ( + !conversationId || + (!text?.trim() && (!attachments || attachments.length === 0)) + ) { + return NextResponse.json( + { error: "conversationId and text are required." }, + { status: 400 }, + ); + } + + const participant = await prisma.conversationParticipant.findUnique({ + where: { + conversationId_userId: { conversationId, userId: session.user.id }, + }, + }); + + if (!participant) { + return NextResponse.json({ error: "Forbidden." }, { status: 403 }); + } + + const message = await prisma.message.create({ + data: { + conversationId, + senderId: session.user.id, + text: text?.trim() ?? "", + attachments: attachments ?? [], + }, + }); + + const payload = { + id: message.id, + conversation_id: message.conversationId, + sender_id: message.senderId, + text: message.text, + attachments: message.attachments, + created_at: message.createdAt.toISOString(), + }; + + emitter.emit(`chat:${conversationId}`, { type: "message", data: payload }); + + const participants = await prisma.conversationParticipant.findMany({ + where: { conversationId, userId: { not: session.user.id } }, + select: { userId: true }, + }); + + for (const p of participants) { + emitter.emit(`user:${p.userId}`, { + type: "new_message", + data: { conversation_id: conversationId, sender_id: session.user.id }, + }); + } + + return NextResponse.json(payload, { status: 201 }); +} diff --git a/app/api/presence/route.ts b/app/api/presence/route.ts new file mode 100644 index 0000000..c64c7a2 --- /dev/null +++ b/app/api/presence/route.ts @@ -0,0 +1,19 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/app/lib/auth"; +import { prisma } from "@/app/lib/prisma"; + +export async function PATCH() { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const timestamp = new Date(); + + await prisma.conversationParticipant.updateMany({ + where: { userId: session.user.id }, + data: { lastSeenAt: timestamp }, + }); + + return NextResponse.json({ success: true }); +} diff --git a/app/api/profile/route.ts b/app/api/profile/route.ts new file mode 100644 index 0000000..7f785db --- /dev/null +++ b/app/api/profile/route.ts @@ -0,0 +1,26 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/app/lib/auth"; +import { prisma } from "@/app/lib/prisma"; + +export async function PATCH(req: Request) { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { name } = await req.json(); + + if (!name?.trim()) { + return NextResponse.json( + { error: "Display name cannot be empty." }, + { status: 400 }, + ); + } + + await prisma.user.update({ + where: { id: session.user.id }, + data: { name: name.trim() }, + }); + + return NextResponse.json({ success: true }); +} diff --git a/app/api/sse/chat/[conversationId]/route.ts b/app/api/sse/chat/[conversationId]/route.ts new file mode 100644 index 0000000..6325b20 --- /dev/null +++ b/app/api/sse/chat/[conversationId]/route.ts @@ -0,0 +1,54 @@ +import { auth } from "@/app/lib/auth"; +import { emitter } from "@/app/lib/emitter"; +import { prisma } from "@/app/lib/prisma"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function GET( + req: Request, + { params }: { params: Promise<{ conversationId: string }> }, +) { + const session = await auth(); + if (!session?.user?.id) { + return new Response("Unauthorized", { status: 401 }); + } + + const { conversationId } = await params; + + const participant = await prisma.conversationParticipant.findUnique({ + where: { + conversationId_userId: { conversationId, userId: session.user.id }, + }, + }); + + if (!participant) { + return new Response("Forbidden", { status: 403 }); + } + + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + start(controller) { + const send = (event: unknown) => { + controller.enqueue( + encoder.encode(`data: ${JSON.stringify(event)}\n\n`), + ); + }; + + emitter.on(`chat:${conversationId}`, send); + + req.signal.addEventListener("abort", () => { + emitter.off(`chat:${conversationId}`, send); + controller.close(); + }); + }, + }); + + return new Response(stream, { + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache, no-transform", + Connection: "keep-alive", + }, + }); +} diff --git a/app/api/sse/chat/[conversationId]/typing/route.ts b/app/api/sse/chat/[conversationId]/typing/route.ts new file mode 100644 index 0000000..51b3de6 --- /dev/null +++ b/app/api/sse/chat/[conversationId]/typing/route.ts @@ -0,0 +1,40 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/app/lib/auth"; +import { emitter } from "@/app/lib/emitter"; +import { prisma } from "@/app/lib/prisma"; + +export async function POST( + req: Request, + { params }: { params: Promise<{ conversationId: string }> }, +) { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { conversationId } = await params; + + const participant = await prisma.conversationParticipant.findUnique({ + where: { + conversationId_userId: { conversationId, userId: session.user.id }, + }, + }); + + if (!participant) { + return NextResponse.json({ error: "Forbidden." }, { status: 403 }); + } + + const { is_typing } = await req.json(); + + emitter.emit(`chat:${conversationId}`, { + type: "typing", + data: { + conversation_id: conversationId, + user_id: session.user.id, + email: participant.email, + is_typing: Boolean(is_typing), + }, + }); + + return NextResponse.json({ success: true }); +} diff --git a/app/api/sse/conversations/route.ts b/app/api/sse/conversations/route.ts new file mode 100644 index 0000000..46023ab --- /dev/null +++ b/app/api/sse/conversations/route.ts @@ -0,0 +1,40 @@ +import { auth } from "@/app/lib/auth"; +import { emitter } from "@/app/lib/emitter"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function GET(req: Request) { + const session = await auth(); + if (!session?.user?.id) { + return new Response("Unauthorized", { status: 401 }); + } + + const userId = session.user.id; + const encoder = new TextEncoder(); + + const stream = new ReadableStream({ + start(controller) { + const send = (event: unknown) => { + controller.enqueue( + encoder.encode(`data: ${JSON.stringify(event)}\n\n`), + ); + }; + + emitter.on(`user:${userId}`, send); + + req.signal.addEventListener("abort", () => { + emitter.off(`user:${userId}`, send); + controller.close(); + }); + }, + }); + + return new Response(stream, { + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache, no-transform", + Connection: "keep-alive", + }, + }); +} diff --git a/app/api/sse/kanban/route.ts b/app/api/sse/kanban/route.ts new file mode 100644 index 0000000..077bc36 --- /dev/null +++ b/app/api/sse/kanban/route.ts @@ -0,0 +1,34 @@ +import { auth } from "@/app/lib/auth"; +import { emitter } from "@/app/lib/emitter"; + +export async function GET(req: Request) { + const session = await auth(); + if (!session?.user?.id) { + return new Response("Unauthorized", { status: 401 }); + } + + const stream = new ReadableStream({ + start(controller) { + const encoder = new TextEncoder(); + + const send = (data: unknown) => { + controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`)); + }; + + emitter.on("kanban", send); + + req.signal.addEventListener("abort", () => { + emitter.off("kanban", send); + controller.close(); + }); + }, + }); + + return new Response(stream, { + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }, + }); +} diff --git a/app/api/users/badges/route.ts b/app/api/users/badges/route.ts new file mode 100644 index 0000000..fbe884e --- /dev/null +++ b/app/api/users/badges/route.ts @@ -0,0 +1,29 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/app/lib/auth"; +import { prisma } from "@/app/lib/prisma"; + +export async function GET(req: Request) { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { searchParams } = new URL(req.url); + const ids = searchParams.getAll("id"); + + if (ids.length === 0) { + return NextResponse.json([]); + } + + const stats = await prisma.userStats.findMany({ + where: { userId: { in: ids } }, + select: { userId: true, totalSeconds: true }, + }); + + return NextResponse.json( + stats.map((s) => ({ + user_id: s.userId, + total_seconds: Number(s.totalSeconds), + })), + ); +} diff --git a/app/api/users/route.ts b/app/api/users/route.ts new file mode 100644 index 0000000..4b6c976 --- /dev/null +++ b/app/api/users/route.ts @@ -0,0 +1,35 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/app/lib/auth"; +import { prisma } from "@/app/lib/prisma"; + +export async function GET(req: Request) { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { searchParams } = new URL(req.url); + const conversationId = searchParams.get("conversationId"); + + if (!conversationId) { + return NextResponse.json( + { error: "conversationId is required." }, + { status: 400 }, + ); + } + + const participants = await prisma.conversationParticipant.findMany({ + where: { + conversationId, + userId: { not: session.user.id }, + }, + select: { userId: true, email: true }, + }); + + const users = participants + .filter((p) => p.email) + .map((p) => ({ user_id: p.userId, email: p.email })) + .sort((a, b) => a.email.localeCompare(b.email)); + + return NextResponse.json(users); +} diff --git a/app/api/wakatime/sync/route.ts b/app/api/wakatime/sync/route.ts index 5b2472c..6700258 100644 --- a/app/api/wakatime/sync/route.ts +++ b/app/api/wakatime/sync/route.ts @@ -1,6 +1,5 @@ import { NextResponse } from "next/server"; -import { createClient } from "../../../lib/supabase/server"; -import { getUserWithProfile } from "@/app/lib/supabase/help/user"; +import { getCurrentUser } from "@/app/lib/auth/user"; import { saveWakatimeApiKey, syncWakatimeData, @@ -8,8 +7,7 @@ import { } from "@/app/lib/wakatime/sync"; export async function GET(request: Request) { - const supabase = await createClient(); - const { user, profile } = await getUserWithProfile(); + const { user } = await getCurrentUser(); const { searchParams } = new URL(request.url); const apiKey = searchParams.get("apiKey") || ""; const saveOnly = @@ -18,10 +16,7 @@ export async function GET(request: Request) { const validationError = validateWakatimeApiKey(apiKey); if (validationError) { - return NextResponse.json( - { error: validationError }, - { status: 400 }, - ); + return NextResponse.json({ error: validationError }, { status: 400 }); } if (!user) { @@ -29,32 +24,29 @@ export async function GET(request: Request) { } if (saveOnly) { - const result = await saveWakatimeApiKey({ - supabase, - userId: user.id, - apiKey, - }); + const result = await saveWakatimeApiKey({ userId: user.id, apiKey }); if (!result.success) { - return NextResponse.json({ error: result.error }, { status: result.status }); + return NextResponse.json( + { error: result.error }, + { status: result.status }, + ); } - return NextResponse.json({ - success: true, - data: null, - error: null, - }); + return NextResponse.json({ success: true, data: null, error: null }); } const result = await syncWakatimeData({ - supabase, userId: user.id, incomingApiKey: apiKey, - storedApiKey: profile?.wakatime_api_key, + storedApiKey: user.wakatimeApiKey, }); if (!result.success && result.status !== 200) { - return NextResponse.json({ error: result.error }, { status: result.status }); + return NextResponse.json( + { error: result.error }, + { status: result.status }, + ); } return NextResponse.json({ diff --git a/app/components/BoardList.tsx b/app/components/BoardList.tsx index e4b3fe1..18fcd86 100644 --- a/app/components/BoardList.tsx +++ b/app/components/BoardList.tsx @@ -1,26 +1,40 @@ "use client"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { createClient } from "../lib/supabase/client"; import Link from "next/link"; import { useState } from "react"; import { createPortal } from "react-dom"; -import { faKey, faRotateRight, faTrashAlt, faChevronRight, faServer, faRightFromBracket } from "@fortawesome/free-solid-svg-icons"; +import { + faKey, + faRotateRight, + faTrashAlt, + faChevronRight, + faServer, + faRightFromBracket, +} from "@fortawesome/free-solid-svg-icons"; import { toast } from "react-toastify"; -import { Leaderboard } from "./dashboard/LeaderbordList"; -import { User } from "@supabase/supabase-js"; + +interface BoardShape { + id: string; + name: string; + slug: string; + owner_id: string; +} + +interface UserShape { + id: string; + email: string; +} export default function BoardList({ user, board, allowLeave = false, }: { - user: User; - board: Leaderboard; - /** When true (joined networks only), show Leave to remove membership */ + user: UserShape; + board: BoardShape; allowLeave?: boolean; }) { - const supabase = createClient(); const [showCodeModal, setShowCodeModal] = useState(false); const [selectedCode, setSelectedCode] = useState(null); const inviteUrl = @@ -32,28 +46,27 @@ export default function BoardList({ const [leaving, setLeaving] = useState(false); const handleDelete = async () => { - const { error } = await supabase - .from("leaderboards") - .delete() - .eq("id", board.id); - - if (error) setShowDeleteModal(false); + const res = await fetch(`/api/leaderboards/${board.id}`, { + method: "DELETE", + }); + if (!res.ok) { + setShowDeleteModal(false); + return; + } window.location.reload(); }; const handleLeave = async () => { setLeaving(true); - const { error } = await supabase - .from("leaderboard_members") - .delete() - .eq("leaderboard_id", board.id) - .eq("user_id", user.id); - + const res = await fetch(`/api/leaderboards/${board.id}/leave`, { + method: "DELETE", + }); setLeaving(false); setShowLeaveModal(false); - if (error) { - toast.error(error.message || "Could not leave this leaderboard."); + if (!res.ok) { + const data = await res.json(); + toast.error(data.error || "Could not leave this leaderboard."); return; } toast.success("You left the leaderboard."); @@ -61,22 +74,12 @@ export default function BoardList({ }; const regenerateJoinCode = (boardId: string) => { - const generateJoinCode = new Promise(async (resolve, reject) => { - try { - const joinCode = crypto.randomUUID().slice(0, 8); - const { data, error } = await supabase - .from("leaderboards") - .update({ join_code: joinCode }) - .eq("id", boardId) - .select() - .single(); - - if (error) return reject(error); - - resolve(data); - } catch (error) { - reject(error); - } + const generateJoinCode = fetch(`/api/leaderboards/${boardId}/join-code`, { + method: "PATCH", + }).then(async (res) => { + const data = await res.json(); + if (!res.ok) throw new Error(data.error); + return data; }); toast.promise(generateJoinCode, { @@ -95,20 +98,11 @@ export default function BoardList({ }; const getJoinCode = (boardId: string) => { - const joinCode: Promise<{ join_code: string }[]> = new Promise( - async (resolve, reject) => { - try { - const { data, error } = await supabase - .from("leaderboards") - .select("join_code") - .eq("id", boardId) - - if (error) return reject(error); - - resolve(data); - } catch (error) { - reject(error); - } + const joinCode = fetch(`/api/leaderboards/${boardId}/join-code`).then( + async (res) => { + const data = await res.json(); + if (!res.ok) throw new Error(data.error); + return data.joinCode as string; }, ); @@ -117,13 +111,13 @@ export default function BoardList({ error: { render({ data }) { const err = data as Error; - return err?.message || "Failed to get join code. Please try again."; + return err?.message || "Failed to get join code. Please try again."; }, }, }); - joinCode.then((data) => { - setSelectedCode(data[0].join_code); + joinCode.then((code) => { + setSelectedCode(code); setShowCodeModal(true); }); }; @@ -131,9 +125,15 @@ export default function BoardList({ return ( <>
- +
- +
@@ -145,9 +145,12 @@ export default function BoardList({ /{board.slug}

- +
- +
@@ -185,7 +188,10 @@ export default function BoardList({ className="w-8 h-8 flex items-center justify-center rounded-md text-gray-500 hover:text-amber-400 hover:bg-amber-500/10 transition-colors" title="Leave leaderboard" > - +
)} @@ -195,7 +201,7 @@ export default function BoardList({
- +

Share Server

@@ -244,51 +250,61 @@ export default function BoardList({
)} - {showLeaveModal && typeof document !== "undefined" && createPortal( -
-
-
-

Leave leaderboard?

-

- You'll be removed from{" "} - {board.name}. - You can rejoin later with an invite link or code. -

-
- - + {showLeaveModal && + typeof document !== "undefined" && + createPortal( +
+
+
+

+ Leave leaderboard? +

+

+ You'll be removed from{" "} + + {board.name} + + . You can rejoin later with an invite link or code. +

+
+ + +
-
-
, - document.body - )} +
, + document.body, + )} {showDeleteModal && (
- +

Delete Network

- Are you sure you want to delete {board.name}? This action cannot be undone. + Are you sure you want to delete{" "} + + {board.name} + + ? This action cannot be undone.

- +
-
-
- - setMessageSearch(e.target.value)} - placeholder="Search Message..." - className="w-full bg-[rgba(10,10,30,0.6)] border border-transparent rounded-xl py-2 pl-9 pr-4 text-sm text-gray-200 placeholder:text-gray-500 outline-none focus:border-indigo-500/50 transition-colors shadow-inner" - /> -
-
- -
-
-

ROOMS

- +
+ {/* Left Sidebar */} +
+
+
+

+ Message category +

+ +
+
+ + setMessageSearch(e.target.value)} + placeholder="Search Message..." + className="w-full bg-[rgba(10,10,30,0.6)] border border-transparent rounded-xl py-2 pl-9 pr-4 text-sm text-gray-200 placeholder:text-gray-500 outline-none focus:border-indigo-500/50 transition-colors shadow-inner" + /> +
-
-
-

DIRECT MESSAGE

-
- setIsDmSortOpen(!isDmSortOpen)} - className="text-[10px] text-gray-500 bg-[rgba(10,10,30,0.6)] px-2 py-0.5 rounded cursor-pointer hover:bg-white/5 flex items-center gap-1 select-none" - > - {dmSortOrder === "newest" && "Newest"} - {dmSortOrder === "oldest" && "Oldest"} - {dmSortOrder === "az" && "A-Z"} - {dmSortOrder === "za" && "Z-A"} - - - - {isDmSortOpen && ( -
- - - - -
- )} -
+
+
+

+ ROOMS +

+
- -
-
-
- {/* Middle Chat Area */} -
- {conversationId ? ( - <> - {/* Header */} -
-
- +
+
+

+ DIRECT MESSAGE +

-
- {activeInitials} -
- {!isGlobalActive && activeOtherUserOnline && ( -
+ setIsDmSortOpen(!isDmSortOpen)} + className="text-[10px] text-gray-500 bg-[rgba(10,10,30,0.6)] px-2 py-0.5 rounded cursor-pointer hover:bg-white/5 flex items-center gap-1 select-none" + > + {dmSortOrder === "newest" && "Newest"} + {dmSortOrder === "oldest" && "Oldest"} + {dmSortOrder === "az" && "A-Z"} + {dmSortOrder === "za" && "Z-A"} + + + + {isDmSortOpen && ( +
+ + + + +
)}
-
-

{activeLabel}

-

{activeSublabel}

-
-
-
-
-
- -
-
+
+
- {activeTypingState && ( -
-
-
- - - -
- {typingIndicatorText} -
-
- )} - - {/* Input Area */} -
- {attachments.length > 0 && ( -
- {attachments.map((file, index) => ( + {/* Middle Chat Area */} +
+ {conversationId ? ( + <> + {/* Header */} +
+
+ +
- {file.type.startsWith("image/") ? ( - {file.name} - ) : ( - - )} - {file.name} - + {activeInitials}
- ))} -
- )} - -
- - - -
-