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/README.md b/README.md index 9c7f2e7..1146fbd 100644 --- a/README.md +++ b/README.md @@ -1,28 +1,16 @@ -Screenshot of the floating console extension in action - +Devpulse + # devpulse Measure and share your coding productivity with personalized leaderboards. Compare your progress with peers while keeping full control over privacy and leaderboard settings with project management features. ## Getting Started + Install the dependencies: npm install -``` - -## Supabase -First by creating a supabase cloud project: - -- go to [Supabase Dashboard](https://app.supabase.com) -- click `New Project` -- choose: - - Organization → (create one if needed) - - Project Name → e.g. devpulse - - Database Password → choose a secure one - - Region → pick the nearest location -- Click Create new project -- Wait a few moments for the database to be provisioned. +```` ## Setup Environment @@ -30,11 +18,9 @@ Copy the .env.example to .env ```bash cp .env.example .env +```` -# Open .env and fill in the values for: -# NEXT_PUBLIC_SUPABASE_URL=your_supabase_url -# NEXT_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key -``` +and fill in the values for the environment variables. ## Development @@ -46,80 +32,6 @@ npm run dev Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. -## Database Migrations - -For a brand-new Supabase project, use this flow from the repo root. - -This repository now uses a squashed baseline migration for fresh installs: - -- Active baseline: `supabase/migrations/20260407120000_baseline_fresh_setup.sql` - -- Historical migrations archive: `supabase/migrations_archive/` - -1. Login to Supabase CLI: - -```bash -npx supabase login -``` - -2. Initialize local Supabase config (only if missing): - -```bash -npx supabase init -``` - -3. Link this repo to your cloud project: - -```bash -npx supabase link -``` - -You can select from the project list, or run `npx supabase link --project-ref `. - -4. Push all migrations to the new project: - -```bash -npx supabase db push -``` - -On a fresh project this applies only the single baseline migration. - -5. (Optional) Pull remote schema changes into migrations: - -```bash -npx supabase db pull -``` - -6. Regenerate Supabase TypeScript types after schema changes: - -```bash -npx supabase gen types typescript --project-id --schema public > app/supabase-types.ts -``` - -If you want to re-run the full migration chain on local development: - -```bash -npx supabase db reset -``` - -If you need to inspect migration history, use the files in `supabase/migrations_archive/`. - - -## Learn More - -To learn more about Next.js, take a look at the following resources: - -- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. -- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. - -You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! - -## Deploy on Vercel - -The easiest way to deploy this Next.js app is to use the [Vercel Platform](https://vercel.com/new/clone?repository-url=https://github.com/mrepol742/devpulse) from the creators of Next.js. - -Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. - ## Contribution Guidelines Contributions to devpulse are welcome! Please follow these guidelines: @@ -135,4 +47,5 @@ Contributions to devpulse are welcome! Please follow these guidelines: Help us keep the codebase ("Devpulse") clean, stable, and maintainable. ## License + This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. 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}
- ))} -
- )} - -
- - - -
-