diff --git a/app/(auth)/auth/magic-link/verify/page.tsx b/app/(auth)/auth/magic-link/verify/page.tsx new file mode 100644 index 0000000..7444fd6 --- /dev/null +++ b/app/(auth)/auth/magic-link/verify/page.tsx @@ -0,0 +1,129 @@ +"use client"; + +import { useEffect, useState, Suspense } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { authClient } from "@/lib/auth-client"; +import { toast } from "sonner"; +import { Loader2, CheckCircle2, AlertCircle } from "lucide-react"; +import { + Card, + CardHeader, + CardTitle, + CardDescription, + CardFooter, +} from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import Link from "next/link"; + +function VerifyContent() { + const router = useRouter(); + const searchParams = useSearchParams(); + const defaultCallback = "/bounty"; + const [status, setStatus] = useState<"loading" | "success" | "error">( + "loading", + ); + const [error, setError] = useState(null); + + useEffect(() => { + const verifyToken = async () => { + const token = searchParams.get("token"); + const rawCallbackURL = searchParams.get("callbackURL") ?? defaultCallback; + const isSafeRelativeCallback = + rawCallbackURL.startsWith("/") && + !rawCallbackURL.startsWith("//") && + !rawCallbackURL.includes("\\") && + !/^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(rawCallbackURL); + const validatedCallback = isSafeRelativeCallback + ? rawCallbackURL + : defaultCallback; + + if (!token) { + setStatus("error"); + setError("Missing verification token."); + return; + } + + try { + const { error } = await authClient.magicLink.verify({ + query: { + token, + callbackURL: validatedCallback, + }, + }); + + if (error) { + setStatus("error"); + setError(error.message || "Failed to verify magic link."); + } else { + setStatus("success"); + toast.success("Successfully verified! Redirecting..."); + // Redirect is handled by Better Auth if successful, + // but we can also manually redirect if needed after a short delay + setTimeout(() => { + router.push(validatedCallback); + }, 2000); + } + } catch (err) { + setStatus("error"); + setError("An unexpected error occurred. Please try again."); + console.error(err); + } + }; + + verifyToken(); + }, [searchParams, router]); + + return ( +
+ + +
+ {status === "loading" && ( + + )} + {status === "success" && ( + + )} + {status === "error" && ( + + )} +
+ + {status === "loading" && "Verifying your magic link"} + {status === "success" && "Verification successful"} + {status === "error" && "Verification failed"} + + + {status === "loading" && + "Please wait while we confirm your identity..."} + {status === "success" && + "You've been successfully signed in. Redirecting you now..."} + {status === "error" && + (error || "The magic link is invalid or has expired.")} + +
+ {status === "error" && ( + + + + )} +
+
+ ); +} + +export default function MagicLinkVerifyPage() { + return ( + + + + } + > + + + ); +} diff --git a/app/api/bounties/[id]/applications/route.ts b/app/api/bounties/[id]/applications/route.ts deleted file mode 100644 index aeb8c10..0000000 --- a/app/api/bounties/[id]/applications/route.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { NextResponse } from 'next/server'; -import { BountyStore } from '@/lib/store'; - -export async function GET( - request: Request, - { params }: { params: Promise<{ id: string }> } -) { - const { id: bountyId } = await params; - const applications = BountyStore.getApplicationsByBounty(bountyId); - return NextResponse.json({ data: applications }); -} diff --git a/app/api/bounties/[id]/apply/route.ts b/app/api/bounties/[id]/apply/route.ts deleted file mode 100644 index 788fe8a..0000000 --- a/app/api/bounties/[id]/apply/route.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { NextResponse } from 'next/server'; -import { BountyStore } from '@/lib/store'; -import { Application } from '@/types/participation'; - -const generateId = () => crypto.randomUUID(); - -export async function POST( - request: Request, - { params }: { params: Promise<{ id: string }> } -) { - const { id: bountyId } = await params; - try { - const body = await request.json(); - const { applicantId, coverLetter, portfolioUrl } = body; - - if (!applicantId || !coverLetter) { - return NextResponse.json({ error: 'Missing required fields' }, { status: 400 }); - } - - const bounty = BountyStore.getBountyById(bountyId); - if (!bounty) { - return NextResponse.json({ error: 'Bounty not found' }, { status: 404 }); - } - - const existingApplication = BountyStore.getApplicationsByBounty(bountyId).find( - (app) => app.applicantId === applicantId - ); - - if (existingApplication) { - return NextResponse.json({ error: 'Application already exists' }, { status: 409 }); - } - - const application: Application = { - id: generateId(), - bountyId: bountyId, - applicantId, - coverLetter, - portfolioUrl, - status: 'pending', - submittedAt: new Date().toISOString(), - }; - - BountyStore.addApplication(application); - - return NextResponse.json({ success: true, data: application }); - } catch { - return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 }); - } -} diff --git a/app/api/bounties/[id]/claim/route.ts b/app/api/bounties/[id]/claim/route.ts deleted file mode 100644 index dd54b7f..0000000 --- a/app/api/bounties/[id]/claim/route.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { NextResponse } from "next/server"; -import { BountyStore } from "@/lib/store"; -import { getCurrentUser } from "@/lib/server-auth"; - -export async function POST( - request: Request, - { params }: { params: Promise<{ id: string }> }, -) { - const { id: bountyId } = await params; - - try { - const user = await getCurrentUser(); - if (!user) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - - const body = await request.json(); - const { contributorId } = body; - - // If client sends contributorId, ensure it matches the authenticated user - if (contributorId && contributorId !== user.id) { - return NextResponse.json( - { error: "Contributor ID mismatch" }, - { status: 403 }, - ); - } - - // Use the validated contributorId or default to the authenticated user - const finalContributorId = contributorId ?? user.id; - - const bounty = BountyStore.getBountyById(bountyId); - if (!bounty) { - return NextResponse.json({ error: "Bounty not found" }, { status: 404 }); - } - - if (bounty.type !== "FIXED_PRICE") { - return NextResponse.json( - { error: "Invalid bounty type for this action" }, - { status: 400 }, - ); - } - - if (bounty.status !== "OPEN") { - return NextResponse.json( - { error: "Bounty is not available" }, - { status: 409 }, - ); - } - - const now = new Date(); - const updates = { - status: "IN_PROGRESS" as const, - updatedAt: now.toISOString(), - claimedBy: finalContributorId, - claimedAt: now.toISOString(), - }; - - const updatedBounty = BountyStore.updateBounty(bountyId, updates); - - if (!updatedBounty) { - return NextResponse.json( - { success: false, error: "Failed to update bounty" }, - { status: 500 }, - ); - } - - return NextResponse.json({ success: true, data: updatedBounty }); - } catch (error) { - console.error("Error claiming bounty:", error); - return NextResponse.json( - { error: "Internal Server Error" }, - { status: 500 }, - ); - } -} diff --git a/app/api/bounties/[id]/competition/join/route.ts b/app/api/bounties/[id]/competition/join/route.ts deleted file mode 100644 index 517e32d..0000000 --- a/app/api/bounties/[id]/competition/join/route.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { NextResponse } from "next/server"; -import { BountyStore } from "@/lib/store"; -import { CompetitionParticipation } from "@/types/participation"; -import { getCurrentUser } from "@/lib/server-auth"; - -const generateId = () => crypto.randomUUID(); - -export async function POST( - request: Request, - { params }: { params: Promise<{ id: string }> }, -) { - const { id: bountyId } = await params; - - try { - const user = await getCurrentUser(); - if (!user) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - - const bounty = BountyStore.getBountyById(bountyId); - if (!bounty) { - return NextResponse.json({ error: "Bounty not found" }, { status: 404 }); - } - - if (bounty.type !== "COMPETITION") { - return NextResponse.json( - { error: "Invalid bounty type for this action" }, - { status: 400 }, - ); - } - - // Validate status is open - if (bounty.status !== "OPEN") { - return NextResponse.json( - { error: "Bounty is not open for registration" }, - { status: 409 }, - ); - } - - const existing = BountyStore.getCompetitionParticipationsByBounty( - bountyId, - ).find((p) => p.contributorId === user.id); - - if (existing) { - return NextResponse.json( - { error: "Already joined this competition" }, - { status: 409 }, - ); - } - - const participation: CompetitionParticipation = { - id: generateId(), - bountyId, - contributorId: user.id, // Use authenticated user ID - status: "registered", - registeredAt: new Date().toISOString(), - }; - - BountyStore.addCompetitionParticipation(participation); - - return NextResponse.json({ success: true, data: participation }); - } catch (error) { - console.error("Error joining competition:", error); - return NextResponse.json( - { error: "Internal Server Error" }, - { status: 500 }, - ); - } -} diff --git a/app/api/bounties/[id]/join/route.ts b/app/api/bounties/[id]/join/route.ts deleted file mode 100644 index 5aa4016..0000000 --- a/app/api/bounties/[id]/join/route.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { NextResponse } from "next/server"; -import { BountyStore } from "@/lib/store"; -import { MilestoneParticipation } from "@/types/participation"; -import { getCurrentUser } from "@/lib/server-auth"; - -const generateId = () => crypto.randomUUID(); - -export async function POST( - request: Request, - { params }: { params: Promise<{ id: string }> }, -) { - const { id: bountyId } = await params; - - try { - const user = await getCurrentUser(); - if (!user) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - - const contributorId = user.id; - - const bounty = BountyStore.getBountyById(bountyId); - if (!bounty) { - return NextResponse.json({ error: "Bounty not found" }, { status: 404 }); - } - - if (bounty.type !== "MILESTONE_BASED") { - return NextResponse.json( - { error: "Invalid bounty type" }, - { status: 400 }, - ); - } - - // Only allow joining when bounty is open - if (bounty.status !== "OPEN") { - return NextResponse.json({ error: "Bounty not open" }, { status: 400 }); - } - - // Check if already joined - const existing = BountyStore.getMilestoneParticipationsByBounty( - bountyId, - ).find((p) => p.contributorId === contributorId); - - if (existing) { - return NextResponse.json( - { error: "Already joined this bounty" }, - { status: 409 }, - ); - } - - const participation: MilestoneParticipation = { - id: generateId(), - bountyId, - contributorId, - currentMilestone: 1, // Start at milestone 1 - status: "active", - joinedAt: new Date().toISOString(), - lastUpdatedAt: new Date().toISOString(), - }; - - BountyStore.addMilestoneParticipation(participation); - - return NextResponse.json({ success: true, data: participation }); - } catch { - return NextResponse.json( - { error: "Internal Server Error" }, - { status: 500 }, - ); - } -} diff --git a/app/api/bounties/[id]/milestones/advance/route.ts b/app/api/bounties/[id]/milestones/advance/route.ts deleted file mode 100644 index fdd74e0..0000000 --- a/app/api/bounties/[id]/milestones/advance/route.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { NextResponse } from "next/server"; -import { BountyStore } from "@/lib/store"; -import { getCurrentUser } from "@/lib/server-auth"; -// import { MilestoneStatus } from '@/types/participation'; - -export async function POST( - request: Request, - { params }: { params: Promise<{ id: string }> }, -) { - const { id: bountyId } = await params; - - try { - const user = await getCurrentUser(); - if (!user) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - - const body = await request.json(); - const { contributorId, action } = body; // action: 'advance' | 'complete' | 'remove' - - // Ensure the authenticated user matches the contributor being modified - if (contributorId !== user.id) { - return NextResponse.json({ error: "Forbidden" }, { status: 403 }); - } - - if (!contributorId || !action) { - return NextResponse.json( - { error: "Missing required fields" }, - { status: 400 }, - ); - } - - if (!["advance", "complete", "remove"].includes(action)) { - return NextResponse.json({ error: "Invalid action" }, { status: 400 }); - } - - const participations = - BountyStore.getMilestoneParticipationsByBounty(bountyId); - const participation = participations.find( - (p) => p.contributorId === contributorId, - ); - - if (!participation) { - return NextResponse.json( - { error: "Participation not found" }, - { status: 404 }, - ); - } - - const bounty = BountyStore.getBountyById(bountyId); - - if (!bounty) { - return NextResponse.json({ error: "Bounty not found" }, { status: 404 }); - } - - const updates: Partial = { - lastUpdatedAt: new Date().toISOString(), - }; - - const totalMilestones = participation.totalMilestones; - - if (action === "advance") { - if (participation.status === "completed") { - return NextResponse.json( - { error: "Cannot advance completed participation" }, - { status: 409 }, - ); - } - if (!totalMilestones) { - return NextResponse.json( - { error: "Cannot determine total milestones" }, - { status: 500 }, - ); - } - if (participation.currentMilestone >= totalMilestones) { - return NextResponse.json( - { error: "Already at last milestone" }, - { status: 409 }, - ); - } - updates.currentMilestone = participation.currentMilestone + 1; - updates.status = "advanced"; - } else if (action === "complete") { - if (participation.status === "completed") { - return NextResponse.json( - { error: "Already completed" }, - { status: 409 }, - ); - } - updates.status = "completed"; - } else if (action === "remove") { - return NextResponse.json( - { error: "Remove action not supported yet" }, - { status: 400 }, - ); - } - - const updated = BountyStore.updateMilestoneParticipation( - participation.id, - updates, - ); - - return NextResponse.json({ success: true, data: updated }); - } catch { - return NextResponse.json( - { error: "Internal Server Error" }, - { status: 500 }, - ); - } -} diff --git a/app/api/bounties/[id]/route.ts b/app/api/bounties/[id]/route.ts deleted file mode 100644 index b8c1f5c..0000000 --- a/app/api/bounties/[id]/route.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { NextResponse } from "next/server"; -import { getBountyById } from "@/lib/mock-bounty"; -import { BountyLogic } from "@/lib/logic/bounty-logic"; - -export async function GET( - request: Request, - { params }: { params: Promise<{ id: string }> }, -) { - // Simulate network delay in development only - if (process.env.NODE_ENV === "development") { - await new Promise((resolve) => setTimeout(resolve, 500)); - } - - const { id } = await params; - const bounty = getBountyById(id); - - if (!bounty) { - return NextResponse.json({ error: "Bounty not found" }, { status: 404 }); - } - - const processed = BountyLogic.processBountyStatus(bounty); - - return NextResponse.json(processed); -} diff --git a/app/api/bounties/[id]/submissions/route.ts b/app/api/bounties/[id]/submissions/route.ts deleted file mode 100644 index 9d0293a..0000000 --- a/app/api/bounties/[id]/submissions/route.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { NextResponse } from 'next/server'; -import { BountyStore } from '@/lib/store'; - -export async function GET( - request: Request, - { params }: { params: Promise<{ id: string }> } -) { - const { id: bountyId } = await params; - const submissions = BountyStore.getSubmissionsByBounty(bountyId); - return NextResponse.json({ data: submissions }); -} diff --git a/app/api/bounties/[id]/submit/route.ts b/app/api/bounties/[id]/submit/route.ts deleted file mode 100644 index 48283bf..0000000 --- a/app/api/bounties/[id]/submit/route.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { NextResponse } from "next/server"; -import { BountyStore } from "@/lib/store"; -import { getCurrentUser } from "@/lib/server-auth"; -import { Submission } from "@/types/participation"; - -const generateId = () => crypto.randomUUID(); - -export async function POST( - request: Request, - { params }: { params: Promise<{ id: string }> }, -) { - const { id: bountyId } = await params; - - try { - const user = await getCurrentUser(); - if (!user) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - - const body = await request.json(); - const { content } = body; - - if (!content) { - return NextResponse.json( - { error: "Missing required fields" }, - { status: 400 }, - ); - } - - const contributorId = user.id; - - const bounty = BountyStore.getBountyById(bountyId); - if (!bounty) { - return NextResponse.json({ error: "Bounty not found" }, { status: 404 }); - } - - // All bounty types can accept submissions - if (bounty.status !== "OPEN" && bounty.status !== "IN_PROGRESS") { - return NextResponse.json( - { error: "Bounty is not accepting submissions" }, - { status: 400 }, - ); - } - - const existingSubmission = BountyStore.getSubmissionsByBounty( - bountyId, - ).find((s) => s.contributorId === contributorId); - - if (existingSubmission) { - return NextResponse.json( - { error: "Duplicate submission" }, - { status: 409 }, - ); - } - - const submission: Submission = { - id: generateId(), - bountyId, - contributorId, - content, - status: "pending", - submittedAt: new Date().toISOString(), - }; - - BountyStore.addSubmission(submission); - - return NextResponse.json({ success: true, data: submission }); - } catch { - return NextResponse.json( - { error: "Internal Server Error" }, - { status: 500 }, - ); - } -} diff --git a/app/api/bounties/route.ts b/app/api/bounties/route.ts deleted file mode 100644 index 7745349..0000000 --- a/app/api/bounties/route.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { NextResponse } from "next/server"; -import { getAllBounties } from "@/lib/mock-bounty"; -import { BountyLogic } from "@/lib/logic/bounty-logic"; - -export async function GET(request: Request) { - const { searchParams } = new URL(request.url); - - // Simulate network delay - await new Promise((resolve) => setTimeout(resolve, 500)); - - const allBounties = getAllBounties().map((b) => - BountyLogic.processBountyStatus(b), - ); - - // Apply filters from params - let filtered = allBounties; - - const status = searchParams.get("status"); - if (status) filtered = filtered.filter((b) => b.status === status); - - const type = searchParams.get("type"); - if (type) filtered = filtered.filter((b) => b.type === type); - - const organizationId = searchParams.get("organizationId"); - if (organizationId) - filtered = filtered.filter((b) => b.organizationId === organizationId); - - const projectId = searchParams.get("projectId"); - if (projectId) filtered = filtered.filter((b) => b.projectId === projectId); - - const search = searchParams.get("search"); - if (search) { - const lower = search.toLowerCase(); - filtered = filtered.filter( - (b) => - b.title.toLowerCase().includes(lower) || - b.description.toLowerCase().includes(lower), - ); - } - - return NextResponse.json({ - data: filtered, - pagination: { - page: 1, - limit: 20, - total: filtered.length, - totalPages: 1, - }, - }); -} diff --git a/components/bounty-detail/bounty-badges.tsx b/components/bounty-detail/bounty-badges.tsx index 64dc0d0..67abcfb 100644 --- a/components/bounty-detail/bounty-badges.tsx +++ b/components/bounty-detail/bounty-badges.tsx @@ -1,8 +1,7 @@ -import type { BountyStatus, BountyType } from "@/types/bounty"; import { STATUS_CONFIG, TYPE_CONFIG } from "@/lib/bounty-config"; -export function StatusBadge({ status }: { status: BountyStatus }) { - const cfg = STATUS_CONFIG[status]; +export function StatusBadge({ status }: { status: string }) { + const cfg = STATUS_CONFIG[status] || STATUS_CONFIG.COMPLETED; return ( { diff --git a/components/bounty/bounty-card.tsx b/components/bounty/bounty-card.tsx index 09e154d..d10a773 100644 --- a/components/bounty/bounty-card.tsx +++ b/components/bounty/bounty-card.tsx @@ -12,16 +12,16 @@ import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Clock } from "lucide-react"; import { cn } from "@/lib/utils"; import { formatDistanceToNow } from "date-fns"; -import { Bounty, BountyStatus } from "@/types/bounty"; +import { BountyFieldsFragment } from "@/lib/graphql/generated"; interface BountyCardProps { - bounty: Bounty; + bounty: BountyFieldsFragment; onClick?: () => void; variant?: "grid" | "list"; } const statusConfig: Record< - BountyStatus, + string, { variant: "default" | "secondary" | "outline" | "destructive"; label: string; diff --git a/components/bounty/bounty-list.tsx b/components/bounty/bounty-list.tsx index 365539d..72c637d 100644 --- a/components/bounty/bounty-list.tsx +++ b/components/bounty/bounty-list.tsx @@ -6,14 +6,16 @@ import { BountyListSkeleton } from "./bounty-card-skeleton"; import { BountyError } from "./bounty-error"; import { BountyEmpty } from "./bounty-empty"; import { useBounties } from "@/hooks/use-bounties"; -import type { BountyListParams } from "@/lib/api"; -import type { Bounty } from "@/types/bounty"; +import { + type BountyQueryInput, + type BountyFieldsFragment, +} from "@/lib/graphql/generated"; interface BountyListProps { - params?: BountyListParams; + params?: BountyQueryInput; hasFilters?: boolean; onClearFilters?: () => void; - onBountyClick?: (bounty: Bounty) => void; + onBountyClick?: (bounty: BountyFieldsFragment) => void; } export function BountyList({ diff --git a/components/bounty/bounty-sidebar.tsx b/components/bounty/bounty-sidebar.tsx index 2b367ed..8331207 100644 --- a/components/bounty/bounty-sidebar.tsx +++ b/components/bounty/bounty-sidebar.tsx @@ -2,25 +2,33 @@ import { useMemo, useState } from "react"; import { RatingModal } from "../rating/rating-modal"; -import { bountiesApi } from "@/lib/api/bounties"; +import { useClaimBounty } from "@/hooks/use-bounty-mutations"; import { Button } from "@/components/ui/button"; import { Separator } from "@/components/ui/separator"; -import type { Bounty } from "@/types/bounty"; +import type { + BountyFieldsFragment, + BountySubmissionType, +} from "@/lib/graphql/generated"; import { Github, Link2, Clock, Calendar, Check, Loader2 } from "lucide-react"; import { formatDistanceToNow } from "date-fns"; import { toast } from "sonner"; interface BountySidebarProps { - bounty: Bounty; + bounty: BountyFieldsFragment & { + submissions?: + | (Pick & { + submittedByUser?: { name?: string | null } | null; + })[] + | null; + }; } export function BountySidebar({ bounty }: BountySidebarProps) { const [copied, setCopied] = useState(false); + const claimBounty = useClaimBounty(); const [loading, setLoading] = useState(false); - // Mock user ID and maintainer check for now - in real app this comes from auth context - const CURRENT_USER_ID = - process.env.NEXT_PUBLIC_MOCK_USER_ID ?? "mock-user-123"; + // Mock maintainer check for now - in real app this comes from auth context const IS_MAINTAINER = process.env.NEXT_PUBLIC_MOCK_MAINTAINER === "true"; if ( @@ -57,20 +65,15 @@ export function BountySidebar({ bounty }: BountySidebarProps) { // Generic action helper removed — unused. Keep specific handlers like `handleClaim`. const handleClaim = async (): Promise => { - setLoading(true); try { - await bountiesApi.claim(bounty.id, CURRENT_USER_ID); + await claimBounty.mutateAsync(bounty.id); toast("Action completed successfully"); - window.location.reload(); return true; } catch (error) { console.error("Claim error:", error); - // Attempt to surface backend message const message = error instanceof Error ? error.message : "Action failed"; - alert(message); + toast.error(message); return false; - } finally { - setLoading(false); } }; @@ -175,10 +178,12 @@ export function BountySidebar({ bounty }: BountySidebarProps) { return ( ); diff --git a/components/projects/project-bounties.tsx b/components/projects/project-bounties.tsx index 68b6da5..1a2c937 100644 --- a/components/projects/project-bounties.tsx +++ b/components/projects/project-bounties.tsx @@ -3,7 +3,11 @@ import { useState } from "react"; import { BountyList } from "@/components/bounty/bounty-list"; import { Badge } from "@/components/ui/badge"; -import type { BountyType, BountyStatus } from "@/types/bounty"; +import { + type BountyQueryInput, + BountyStatus, + BountyType, +} from "@/lib/graphql/generated"; import { cn } from "@/lib/utils"; interface ProjectBountiesProps { @@ -12,21 +16,21 @@ interface ProjectBountiesProps { const bountyTypes: { value: BountyType | "all"; label: string }[] = [ { value: "all", label: "All Types" }, - { value: "FIXED_PRICE", label: "Fixed Price" }, - { value: "MILESTONE_BASED", label: "Milestone" }, - { value: "COMPETITION", label: "Competition" }, + { value: BountyType.FixedPrice, label: "Fixed Price" }, + { value: BountyType.MilestoneBased, label: "Milestone" }, + { value: BountyType.Competition, label: "Competition" }, ]; const statuses: { value: BountyStatus | "all"; label: string }[] = [ { value: "all", label: "All Status" }, - { value: "OPEN", label: "Open" }, - { value: "IN_PROGRESS", label: "In Progress" }, - { value: "COMPLETED", label: "Completed" }, - { value: "CANCELLED", label: "Cancelled" }, - { value: "DRAFT", label: "Draft" }, - { value: "SUBMITTED", label: "Submitted" }, - { value: "UNDER_REVIEW", label: "Under Review" }, - { value: "DISPUTED", label: "Disputed" }, + { value: BountyStatus.Open, label: "Open" }, + { value: BountyStatus.InProgress, label: "In Progress" }, + { value: BountyStatus.Completed, label: "Completed" }, + { value: BountyStatus.Cancelled, label: "Cancelled" }, + { value: BountyStatus.Draft, label: "Draft" }, + { value: BountyStatus.Submitted, label: "Submitted" }, + { value: BountyStatus.UnderReview, label: "Under Review" }, + { value: BountyStatus.Disputed, label: "Disputed" }, ]; export function ProjectBounties({ projectId }: ProjectBountiesProps) { @@ -37,7 +41,7 @@ export function ProjectBounties({ projectId }: ProjectBountiesProps) { // Get all bounties for this project - const params = { + const params: BountyQueryInput = { projectId, ...(selectedType !== "all" && { type: selectedType }), ...(selectedStatus !== "all" && { status: selectedStatus }), diff --git a/hooks/Use-bounty-detail.ts b/hooks/Use-bounty-detail.ts deleted file mode 100644 index 3bf88bf..0000000 --- a/hooks/Use-bounty-detail.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { useQuery } from "@tanstack/react-query"; -import { bountiesApi, type Bounty } from "@/lib/api"; -import { bountyKeys } from "./use-bounties"; - -export function useBountyDetail(id: string) { - return useQuery({ - queryKey: bountyKeys.detail(id), - queryFn: () => bountiesApi.getById(id), - enabled: Boolean(id), - }); -} diff --git a/hooks/use-bounties.ts b/hooks/use-bounties.ts index eeb4ea9..f515fa5 100644 --- a/hooks/use-bounties.ts +++ b/hooks/use-bounties.ts @@ -1,17 +1,30 @@ -import { useQuery } from '@tanstack/react-query'; -import { bountiesApi, type Bounty, type BountyListParams, type PaginatedResponse } from '@/lib/api'; +import { + useBountiesQuery, + type BountyQueryInput, + type BountyFieldsFragment, +} from "@/lib/graphql/generated"; +import { bountyKeys } from "@/lib/query/query-keys"; -export const bountyKeys = { - all: ['bounties'] as const, - lists: () => [...bountyKeys.all, 'list'] as const, - list: (params?: BountyListParams) => [...bountyKeys.lists(), params] as const, - details: () => [...bountyKeys.all, 'detail'] as const, - detail: (id: string) => [...bountyKeys.details(), id] as const, -}; +export { bountyKeys }; -export function useBounties(params?: BountyListParams) { - return useQuery>({ - queryKey: bountyKeys.list(params), - queryFn: () => bountiesApi.list(params), - }); +export function useBounties(params?: BountyQueryInput) { + const { data, ...rest } = useBountiesQuery({ query: params }); + + return { + ...rest, + data: data + ? { + data: data.bounties.bounties as BountyFieldsFragment[], + pagination: { + page: params?.page ?? 1, + limit: data.bounties.limit, + total: data.bounties.total, + totalPages: + data.bounties.limit > 0 + ? Math.ceil(data.bounties.total / data.bounties.limit) + : 0, + }, + } + : undefined, + }; } diff --git a/hooks/use-bounty-detail.ts b/hooks/use-bounty-detail.ts new file mode 100644 index 0000000..2087cd1 --- /dev/null +++ b/hooks/use-bounty-detail.ts @@ -0,0 +1,13 @@ +import { + useBountyQuery, + type BountyFieldsFragment, +} from "@/lib/graphql/generated"; + +export function useBountyDetail(id: string) { + const { data, ...rest } = useBountyQuery({ id }, { enabled: Boolean(id) }); + + return { + ...rest, + data: data?.bounty as BountyFieldsFragment | undefined, + }; +} diff --git a/hooks/use-bounty-mutations.ts b/hooks/use-bounty-mutations.ts index d6e31fd..f4d6f1c 100644 --- a/hooks/use-bounty-mutations.ts +++ b/hooks/use-bounty-mutations.ts @@ -1,39 +1,76 @@ -import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useQueryClient, type MutateOptions } from "@tanstack/react-query"; import { - bountiesApi, - type Bounty, + useCreateBountyMutation, + useUpdateBountyMutation, + useDeleteBountyMutation, type CreateBountyInput, type UpdateBountyInput, - type PaginatedResponse, -} from "@/lib/api"; -import { bountyKeys } from "./use-bounties"; + type BountyQuery, + type BountiesQuery, + type CreateBountyMutation, + type UpdateBountyMutation, + type DeleteBountyMutation, + type CreateBountyMutationVariables, + type UpdateBountyMutationVariables, + type DeleteBountyMutationVariables, +} from "@/lib/graphql/generated"; +import { bountyKeys } from "@/lib/query/query-keys"; export function useCreateBounty() { const queryClient = useQueryClient(); - - return useMutation({ - mutationFn: (data: CreateBountyInput) => bountiesApi.create(data), + const mutation = useCreateBountyMutation({ onSuccess: () => { queryClient.invalidateQueries({ queryKey: bountyKeys.lists() }); }, }); + + return { + ...mutation, + mutate: ( + input: CreateBountyInput, + options?: MutateOptions< + CreateBountyMutation, + unknown, + CreateBountyMutationVariables, + unknown + >, + ) => mutation.mutate({ input }, options), + mutateAsync: ( + input: CreateBountyInput, + options?: MutateOptions< + CreateBountyMutation, + unknown, + CreateBountyMutationVariables, + unknown + >, + ) => mutation.mutateAsync({ input }, options), + }; } export function useUpdateBounty() { const queryClient = useQueryClient(); - - return useMutation({ - mutationFn: ({ id, data }: { id: string; data: UpdateBountyInput }) => - bountiesApi.update(id, data), - onMutate: async ({ id, data }) => { + const mutation = useUpdateBountyMutation({ + onMutate: async (variables) => { + const { id } = variables.input; await queryClient.cancelQueries({ queryKey: bountyKeys.detail(id) }); - const previous = queryClient.getQueryData(bountyKeys.detail(id)); + const previous = queryClient.getQueryData( + bountyKeys.detail(id), + ); - if (previous) { - queryClient.setQueryData(bountyKeys.detail(id), { + if (previous?.bounty) { + const optimisticInput = Object.fromEntries( + Object.entries(variables.input).filter( + ([, value]) => value !== undefined && value !== null, + ), + ) as Partial; + + queryClient.setQueryData(bountyKeys.detail(id), { ...previous, - ...data, - updatedAt: new Date().toISOString(), + bounty: { + ...previous.bounty, + ...optimisticInput, + updatedAt: new Date().toISOString(), + }, }); } @@ -47,37 +84,63 @@ export function useUpdateBounty() { ); } }, - onSettled: (_data, _err, { id }) => { - queryClient.invalidateQueries({ queryKey: bountyKeys.detail(id) }); + onSettled: (_data, _err, variables) => { + queryClient.invalidateQueries({ + queryKey: bountyKeys.detail(variables.input.id), + }); queryClient.invalidateQueries({ queryKey: bountyKeys.lists() }); }, }); + + return { + ...mutation, + mutate: ( + { id, data }: { id: string; data: Omit }, + options?: MutateOptions< + UpdateBountyMutation, + unknown, + UpdateBountyMutationVariables, + unknown + >, + ) => + mutation.mutate({ input: { ...data, id } as UpdateBountyInput }, options), + mutateAsync: ( + { id, data }: { id: string; data: Omit }, + options?: MutateOptions< + UpdateBountyMutation, + unknown, + UpdateBountyMutationVariables, + unknown + >, + ) => + mutation.mutateAsync( + { input: { ...data, id } as UpdateBountyInput }, + options, + ), + }; } export function useDeleteBounty() { const queryClient = useQueryClient(); - - return useMutation({ - mutationFn: (id: string) => bountiesApi.delete(id), - onMutate: async (id) => { + const mutation = useDeleteBountyMutation({ + onMutate: async (variables) => { + const { id } = variables; await queryClient.cancelQueries({ queryKey: bountyKeys.lists() }); - const previousLists = queryClient.getQueriesData< - PaginatedResponse - >({ + const previousLists = queryClient.getQueriesData({ queryKey: bountyKeys.lists(), }); - queryClient.setQueriesData>( + queryClient.setQueriesData( { queryKey: bountyKeys.lists() }, (old) => old ? { ...old, - data: old.data.filter((b) => b.id !== id), - pagination: { - ...old.pagination, - total: old.pagination.total - 1, + bounties: { + ...old.bounties, + bounties: old.bounties.bounties.filter((b) => b.id !== id), + total: old.bounties.total - 1, }, } : old, @@ -85,7 +148,7 @@ export function useDeleteBounty() { return { previousLists }; }, - onError: (_err, _id, context) => { + onError: (_err, _vars, context) => { context?.previousLists.forEach(([key, data]) => { queryClient.setQueryData(key, data); }); @@ -94,18 +157,63 @@ export function useDeleteBounty() { queryClient.invalidateQueries({ queryKey: bountyKeys.lists() }); }, }); + + return { + ...mutation, + mutate: ( + id: string, + options?: MutateOptions< + DeleteBountyMutation, + unknown, + DeleteBountyMutationVariables, + unknown + >, + ) => mutation.mutate({ id }, options), + mutateAsync: ( + id: string, + options?: MutateOptions< + DeleteBountyMutation, + unknown, + DeleteBountyMutationVariables, + unknown + >, + ) => mutation.mutateAsync({ id }, options), + }; } export function useClaimBounty() { const queryClient = useQueryClient(); - - return useMutation({ - mutationFn: (id: string) => bountiesApi.claim(id), - onSuccess: (data, id) => { - queryClient.setQueryData(bountyKeys.detail(id), data); - - // Invalidate the list view so the main bounties board updates + // Claim is treated as an update the status to IN_PROGRESS + const mutation = useUpdateBountyMutation({ + onSuccess: (data, variables) => { + queryClient.setQueryData( + bountyKeys.detail(variables.input.id), + { bounty: data.updateBounty }, + ); queryClient.invalidateQueries({ queryKey: bountyKeys.lists() }); }, }); + + return { + ...mutation, + mutate: ( + id: string, + options?: MutateOptions< + UpdateBountyMutation, + unknown, + UpdateBountyMutationVariables, + unknown + >, + ) => mutation.mutate({ input: { id, status: "IN_PROGRESS" } }, options), + mutateAsync: ( + id: string, + options?: MutateOptions< + UpdateBountyMutation, + unknown, + UpdateBountyMutationVariables, + unknown + >, + ) => + mutation.mutateAsync({ input: { id, status: "IN_PROGRESS" } }, options), + }; } diff --git a/hooks/use-bounty-search.ts b/hooks/use-bounty-search.ts index 4fbc087..0eb8d09 100644 --- a/hooks/use-bounty-search.ts +++ b/hooks/use-bounty-search.ts @@ -1,77 +1,93 @@ -import { useState, useEffect } from 'react'; -import { keepPreviousData, useQuery } from '@tanstack/react-query'; -import { useDebounce } from '@/hooks/use-debounce'; -import { bountiesApi } from '@/lib/api'; -import { bountyKeys } from '@/lib/query/query-keys'; +import { useState, useEffect } from "react"; +import { keepPreviousData, useQuery } from "@tanstack/react-query"; +import { useDebounce } from "@/hooks/use-debounce"; +import { fetcher } from "@/lib/graphql/client"; +import { + BountiesDocument, + type BountiesQuery, + type BountyQueryInput, + type BountyFieldsFragment, +} from "@/lib/graphql/generated"; +import { bountyKeys } from "@/lib/query/query-keys"; -const RECENT_SEARCHES_KEY = 'bounties-recent-searches'; +const RECENT_SEARCHES_KEY = "bounties-recent-searches"; const MAX_RECENT_SEARCHES = 5; export function useBountySearch() { - const [searchTerm, setSearchTerm] = useState(''); - const [isOpen, setIsOpen] = useState(false); - const [recentSearches, setRecentSearches] = useState([]); + const [searchTerm, setSearchTerm] = useState(""); + const [isOpen, setIsOpen] = useState(false); + const [recentSearches, setRecentSearches] = useState([]); - const debouncedSearch = useDebounce(searchTerm, 300); + const debouncedSearch = useDebounce(searchTerm, 300); - // Load recent searches on mount - useEffect(() => { - const saved = localStorage.getItem(RECENT_SEARCHES_KEY); - if (saved) { - try { - // eslint-disable-next-line react-hooks/set-state-in-effect - setRecentSearches(JSON.parse(saved)); - } catch (e) { - console.error('Failed to parse recent searches', e); - } - } - }, []); + // Load recent searches on mount + useEffect(() => { + const saved = localStorage.getItem(RECENT_SEARCHES_KEY); + if (saved) { + try { + // eslint-disable-next-line react-hooks/set-state-in-effect + setRecentSearches(JSON.parse(saved)); + } catch (e) { + console.error("Failed to parse recent searches", e); + } + } + }, []); - const { data, isLoading, isFetching } = useQuery({ - queryKey: bountyKeys.list({ search: debouncedSearch, limit: 5 }), - queryFn: () => bountiesApi.list({ search: debouncedSearch, limit: 5 }), - enabled: debouncedSearch.length > 0 && isOpen, - placeholderData: keepPreviousData, - staleTime: 1000 * 60 * 5, // 5 minutes - }); + const { data, isLoading, isFetching } = useQuery({ + queryKey: bountyKeys.list({ search: debouncedSearch, limit: 5 }), + queryFn: async () => { + const response = await fetcher< + BountiesQuery, + { query: BountyQueryInput } + >(BountiesDocument, { + query: { search: debouncedSearch, limit: 5 }, + })(); + return { + data: response.bounties.bounties as BountyFieldsFragment[], + }; + }, + enabled: debouncedSearch.length > 0 && isOpen, + placeholderData: keepPreviousData, + staleTime: 1000 * 60 * 5, // 5 minutes + }); - const addRecentSearch = (term: string) => { - if (!term.trim()) return; + const addRecentSearch = (term: string) => { + if (!term.trim()) return; - const newRecent = [ - term, - ...recentSearches.filter((t) => t !== term) - ].slice(0, MAX_RECENT_SEARCHES); + const newRecent = [term, ...recentSearches.filter((t) => t !== term)].slice( + 0, + MAX_RECENT_SEARCHES, + ); - setRecentSearches(newRecent); - localStorage.setItem(RECENT_SEARCHES_KEY, JSON.stringify(newRecent)); - }; + setRecentSearches(newRecent); + localStorage.setItem(RECENT_SEARCHES_KEY, JSON.stringify(newRecent)); + }; - const removeRecentSearch = (term: string) => { - const newRecent = recentSearches.filter((t) => t !== term); - setRecentSearches(newRecent); - localStorage.setItem(RECENT_SEARCHES_KEY, JSON.stringify(newRecent)); - }; + const removeRecentSearch = (term: string) => { + const newRecent = recentSearches.filter((t) => t !== term); + setRecentSearches(newRecent); + localStorage.setItem(RECENT_SEARCHES_KEY, JSON.stringify(newRecent)); + }; - const clearRecentSearches = () => { - setRecentSearches([]); - localStorage.removeItem(RECENT_SEARCHES_KEY); - }; + const clearRecentSearches = () => { + setRecentSearches([]); + localStorage.removeItem(RECENT_SEARCHES_KEY); + }; - const toggleOpen = () => setIsOpen((prev) => !prev); + const toggleOpen = () => setIsOpen((prev) => !prev); - return { - searchTerm, - setSearchTerm, - debouncedSearch, - isOpen, - setIsOpen, - toggleOpen, - results: data?.data ?? [], - isLoading: isLoading || isFetching, - recentSearches, - addRecentSearch, - removeRecentSearch, - clearRecentSearches, - }; + return { + searchTerm, + setSearchTerm, + debouncedSearch, + isOpen, + setIsOpen, + toggleOpen, + results: data?.data ?? [], + isLoading: isLoading || isFetching, + recentSearches, + addRecentSearch, + removeRecentSearch, + clearRecentSearches, + }; } diff --git a/hooks/use-bounty.ts b/hooks/use-bounty.ts index 97512e4..029dacf 100644 --- a/hooks/use-bounty.ts +++ b/hooks/use-bounty.ts @@ -1,15 +1,20 @@ -import { useQuery } from '@tanstack/react-query'; -import { bountiesApi, type Bounty } from '@/lib/api'; -import { bountyKeys } from './use-bounties'; +import { + useBountyQuery, + type BountyFieldsFragment, +} from "@/lib/graphql/generated"; interface UseBountyOptions { - enabled?: boolean; + enabled?: boolean; } export function useBounty(id: string, options?: UseBountyOptions) { - return useQuery({ - queryKey: bountyKeys.detail(id), - queryFn: () => bountiesApi.getById(id), - enabled: options?.enabled ?? !!id, - }); + const { data, ...rest } = useBountyQuery( + { id }, + { enabled: options?.enabled ?? !!id }, + ); + + return { + ...rest, + data: data?.bounty as BountyFieldsFragment | undefined, + }; } diff --git a/hooks/use-infinite-bounties.ts b/hooks/use-infinite-bounties.ts index ac86e16..72e2d62 100644 --- a/hooks/use-infinite-bounties.ts +++ b/hooks/use-infinite-bounties.ts @@ -1,29 +1,49 @@ -import { useInfiniteQuery } from '@tanstack/react-query'; -import { bountiesApi, type Bounty, type BountyListParams, type PaginatedResponse } from '@/lib/api'; -import { bountyKeys } from './use-bounties'; +import { useInfiniteQuery } from "@tanstack/react-query"; +import { fetcher } from "@/lib/graphql/client"; +import { + BountiesDocument, + type BountiesQuery, + type BountyQueryInput, + type BountyFieldsFragment, +} from "@/lib/graphql/generated"; +import { type PaginatedResponse } from "@/lib/api/types"; +import { bountyKeys } from "@/lib/query/query-keys"; const DEFAULT_LIMIT = 20; -export function useInfiniteBounties(params?: Omit) { - return useInfiniteQuery>({ - queryKey: [...bountyKeys.lists(), 'infinite', params] as const, - queryFn: ({ pageParam }) => - bountiesApi.list({ ...params, page: pageParam as number, limit: params?.limit ?? DEFAULT_LIMIT }), - initialPageParam: 1, - getNextPageParam: (lastPage) => { - const { page, totalPages } = lastPage.pagination; - return page < totalPages ? page + 1 : undefined; +export function useInfiniteBounties(params?: Omit) { + return useInfiniteQuery>({ + queryKey: bountyKeys.infinite(params), + queryFn: async ({ pageParam }) => { + const response = await fetcher< + BountiesQuery, + { query: BountyQueryInput } + >(BountiesDocument, { + query: { + ...params, + page: pageParam as number, + limit: params?.limit ?? DEFAULT_LIMIT, }, - getPreviousPageParam: (firstPage) => { - const { page } = firstPage.pagination; - return page > 1 ? page - 1 : undefined; + })(); + const data = response.bounties; + return { + data: data.bounties as BountyFieldsFragment[], + pagination: { + page: pageParam as number, + limit: data.limit, + total: data.total, + totalPages: data.limit > 0 ? Math.ceil(data.total / data.limit) : 0, }, - }); -} - -// Helper to flatten infinite query pages -export function flattenBountyPages( - pages: PaginatedResponse[] | undefined -): Bounty[] { - return pages?.flatMap((page) => page.data) ?? []; + }; + }, + initialPageParam: 1, + getNextPageParam: (lastPage) => { + const { page, totalPages } = lastPage.pagination; + return page < totalPages ? page + 1 : undefined; + }, + getPreviousPageParam: (firstPage) => { + const { page } = firstPage.pagination; + return page > 1 ? page - 1 : undefined; + }, + }); } diff --git a/lib/api/bounties.ts b/lib/api/bounties.ts deleted file mode 100644 index 7cc8ed4..0000000 --- a/lib/api/bounties.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { z } from "zod"; -import { get, post, put, del } from "./client"; -import type { PaginatedResponse, PaginationParams, SortParams } from "./types"; - -// Bounty schemas — aligned with backend GraphQL enums -const bountyTypeSchema = z.enum([ - "FIXED_PRICE", - "MILESTONE_BASED", - "COMPETITION", -]); -const bountyStatusSchema = z.enum([ - "OPEN", - "IN_PROGRESS", - "COMPLETED", - "CANCELLED", - "DRAFT", - "SUBMITTED", - "UNDER_REVIEW", - "DISPUTED", -]); - -const bountyOrganizationSchema = z.object({ - id: z.string(), - name: z.string(), - logo: z.string().nullable(), - slug: z.string().nullable(), -}); - -const bountyProjectSchema = z.object({ - id: z.string(), - title: z.string(), - description: z.string().nullable(), -}); - -export const bountySchema = z.object({ - id: z.string(), - title: z.string(), - description: z.string(), - type: bountyTypeSchema, - status: bountyStatusSchema, - - organizationId: z.string(), - organization: bountyOrganizationSchema.nullable().optional(), - projectId: z.string().nullable(), - project: bountyProjectSchema.nullable().optional(), - - githubIssueUrl: z.string(), - githubIssueNumber: z.number().nullable(), - - rewardAmount: z.number(), - rewardCurrency: z.string(), - - bountyWindowId: z.string().nullable().optional(), - - createdBy: z.string(), - createdAt: z.string(), - updatedAt: z.string(), -}); - -export type Bounty = z.infer; -export type BountyType = z.infer; -export type BountyStatus = z.infer; - -// Query params -export interface BountyListParams extends PaginationParams, SortParams { - status?: BountyStatus; - type?: BountyType; - organizationId?: string; - projectId?: string; - bountyWindowId?: string; - search?: string; -} - -// Create bounty input -export const createBountySchema = bountySchema.omit({ - id: true, - createdAt: true, - updatedAt: true, - status: true, - createdBy: true, - organization: true, - project: true, -}); - -export type CreateBountyInput = z.infer; - -// Update bounty input -export const updateBountySchema = createBountySchema.partial(); - -export type UpdateBountyInput = z.infer; - -// API endpoints -const BOUNTIES_ENDPOINT = "/api/bounties"; - -export const bountiesApi = { - list: (params?: BountyListParams): Promise> => { - const queryParams: Record = { ...params }; - return get>(BOUNTIES_ENDPOINT, { - params: queryParams, - }); - }, - - getById: (id: string): Promise => - get(`${BOUNTIES_ENDPOINT}/${id}`), - - create: (data: CreateBountyInput): Promise => - post(BOUNTIES_ENDPOINT, data), - - update: (id: string, data: UpdateBountyInput): Promise => - put(`${BOUNTIES_ENDPOINT}/${id}`, data), - - delete: (id: string): Promise => - del(`${BOUNTIES_ENDPOINT}/${id}`), - - claim: (id: string, contributorId?: string): Promise => { - const body = contributorId ? { contributorId } : {}; - return post(`${BOUNTIES_ENDPOINT}/${id}/claim`, body); - }, -}; - -// Parse and validate response (use when strict validation needed) -export function parseBounty(data: unknown): Bounty { - return bountySchema.parse(data); -} - -export function parseBountyList(data: unknown): Bounty[] { - return z.array(bountySchema).parse(data); -} diff --git a/lib/api/client.ts b/lib/api/client.ts index e3eee44..6e45a0c 100644 --- a/lib/api/client.ts +++ b/lib/api/client.ts @@ -1,121 +1,133 @@ -import axios, { AxiosError, type AxiosInstance, type AxiosRequestConfig, type InternalAxiosRequestConfig } from 'axios'; -import { ApiError, NetworkError, apiErrorResponseSchema } from './errors'; +import axios, { + AxiosError, + type AxiosInstance, + type AxiosRequestConfig, + type InternalAxiosRequestConfig, +} from "axios"; +import { ApiError, NetworkError, apiErrorResponseSchema } from "./errors"; +import { getAccessToken } from "../auth-utils"; -const BASE_URL = process.env.NEXT_PUBLIC_API_URL || ''; +const BASE_URL = process.env.NEXT_PUBLIC_API_URL || ""; -// Token storage keys -const ACCESS_TOKEN_KEY = 'access_token'; - -// Get token from storage (client-side only) -function getAccessToken(): string | null { - if (typeof window === 'undefined') return null; - return localStorage.getItem(ACCESS_TOKEN_KEY); -} - -// Set token in storage -export function setAccessToken(token: string): void { - if (typeof window === 'undefined') return; - localStorage.setItem(ACCESS_TOKEN_KEY, token); -} - -// Clear token from storage -export function clearAccessToken(): void { - if (typeof window === 'undefined') return; - localStorage.removeItem(ACCESS_TOKEN_KEY); -} +// Get token from Better Auth +// (Function removed and replaced by shared utility) // Create axios instance function createApiClient(): AxiosInstance { - const client = axios.create({ - baseURL: BASE_URL, - timeout: 30000, - withCredentials: true, // Send cookies for CSRF protection - headers: { - 'Content-Type': 'application/json', - }, - }); - - // Request interceptor - add auth token - client.interceptors.request.use( - (config: InternalAxiosRequestConfig) => { - const token = getAccessToken(); - if (token && config.headers) { - config.headers.Authorization = `Bearer ${token}`; - } - return config; - }, - (error) => Promise.reject(error) - ); - - // Response interceptor - handle errors - client.interceptors.response.use( - (response) => response, - (error: AxiosError) => { - // Request cancelled - if (axios.isCancel(error)) { - return Promise.reject(error); - } - - // Network error - if (!error.response) { - return Promise.reject(new NetworkError()); - } - - const { status, data } = error.response; - - // Handle auth errors globally - if (status === 401 || status === 403) { - clearAccessToken(); - if (typeof window !== 'undefined') { - window.dispatchEvent(new CustomEvent('auth:unauthorized', { detail: { status } })); - } - } - - // Parse error response - const parsed = apiErrorResponseSchema.safeParse(data); - const errorData = parsed.success ? parsed.data : undefined; - - return Promise.reject(ApiError.fromResponse(status, errorData)); + const client = axios.create({ + baseURL: BASE_URL, + timeout: 30000, + withCredentials: true, // Send cookies for CSRF protection + headers: { + "Content-Type": "application/json", + }, + }); + + // Request interceptor - add auth token + client.interceptors.request.use( + async (config: InternalAxiosRequestConfig) => { + const token = await getAccessToken(); + if (token && config.headers) { + config.headers.Authorization = `Bearer ${token}`; + } + return config; + }, + (error) => Promise.reject(error), + ); + + // Response interceptor - handle errors + client.interceptors.response.use( + (response) => response, + (error: AxiosError) => { + // Request cancelled + if (axios.isCancel(error)) { + return Promise.reject(error); + } + + // Network error + if (!error.response) { + return Promise.reject(new NetworkError()); + } + + const { status, data } = error.response; + + // Handle auth errors globally + if (status === 401 || status === 403) { + if (typeof window !== "undefined") { + window.dispatchEvent( + new CustomEvent("auth:unauthorized", { detail: { status } }), + ); } - ); + } + + // Parse error response + const parsed = apiErrorResponseSchema.safeParse(data); + const errorData = parsed.success ? parsed.data : undefined; - return client; + return Promise.reject(ApiError.fromResponse(status, errorData)); + }, + ); + + return client; } export const apiClient = createApiClient(); // Request options with abort signal support interface RequestOptions { - signal?: AbortSignal; - params?: Record; + signal?: AbortSignal; + params?: Record; } // Type-safe request helpers -export async function get(url: string, options?: RequestOptions): Promise { - const config: AxiosRequestConfig = {}; - if (options?.signal) config.signal = options.signal; - if (options?.params) config.params = options.params; - const response = await apiClient.get(url, config); - return response.data; +export async function get( + url: string, + options?: RequestOptions, +): Promise { + const config: AxiosRequestConfig = {}; + if (options?.signal) config.signal = options.signal; + if (options?.params) config.params = options.params; + const response = await apiClient.get(url, config); + return response.data; } -export async function post(url: string, data?: unknown, options?: { signal?: AbortSignal }): Promise { - const response = await apiClient.post(url, data, { signal: options?.signal }); - return response.data; +export async function post( + url: string, + data?: unknown, + options?: { signal?: AbortSignal }, +): Promise { + const response = await apiClient.post(url, data, { + signal: options?.signal, + }); + return response.data; } -export async function put(url: string, data?: unknown, options?: { signal?: AbortSignal }): Promise { - const response = await apiClient.put(url, data, { signal: options?.signal }); - return response.data; +export async function put( + url: string, + data?: unknown, + options?: { signal?: AbortSignal }, +): Promise { + const response = await apiClient.put(url, data, { + signal: options?.signal, + }); + return response.data; } -export async function patch(url: string, data?: unknown, options?: { signal?: AbortSignal }): Promise { - const response = await apiClient.patch(url, data, { signal: options?.signal }); - return response.data; +export async function patch( + url: string, + data?: unknown, + options?: { signal?: AbortSignal }, +): Promise { + const response = await apiClient.patch(url, data, { + signal: options?.signal, + }); + return response.data; } -export async function del(url: string, options?: { signal?: AbortSignal }): Promise { - const response = await apiClient.delete(url, { signal: options?.signal }); - return response.data; +export async function del( + url: string, + options?: { signal?: AbortSignal }, +): Promise { + const response = await apiClient.delete(url, { signal: options?.signal }); + return response.data; } - diff --git a/lib/api/index.ts b/lib/api/index.ts index d18b79b..db27bc2 100644 --- a/lib/api/index.ts +++ b/lib/api/index.ts @@ -1,14 +1,5 @@ // API Client -export { - apiClient, - get, - post, - put, - patch, - del, - setAccessToken, - clearAccessToken, -} from "./client"; +export { apiClient, get, post, put, patch, del } from "./client"; // Error handling export { @@ -27,19 +18,3 @@ export { type PaginationParams, type SortParams, } from "./types"; - -// Bounties API -export { - bountiesApi, - bountySchema, - createBountySchema, - updateBountySchema, - parseBounty, - parseBountyList, - type Bounty, - type BountyType, - type BountyStatus, - type BountyListParams, - type CreateBountyInput, - type UpdateBountyInput, -} from "./bounties"; diff --git a/lib/auth-utils.ts b/lib/auth-utils.ts new file mode 100644 index 0000000..8ab6685 --- /dev/null +++ b/lib/auth-utils.ts @@ -0,0 +1,42 @@ +import { authClient } from "./auth-client"; + +/** + * Shared utility to get the access token from cookies or Better Auth client. + * Works in both client-side and server-side (Next.js) environments. + */ +export async function getAccessToken(): Promise { + // Server-side + if (typeof window === "undefined") { + try { + const { cookies } = await import("next/headers"); + const cookieStore = await cookies(); + const cookie = cookieStore.get("boundless_auth.session_token"); + return cookie?.value ?? null; + } catch { + return null; + } + } + + // Client-side: Extract the session token from document.cookie + // Better Auth tokens can contain dots and special characters. + // We use a regex to extract the value precisely. + const name = "boundless_auth.session_token"; + const escapedName = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const match = document.cookie.match( + new RegExp("(^| )" + escapedName + "=([^;]+)"), + ); + if (match) { + // We return the decoded value from the cookie. + // If it's URL encoded (e.g. contains %3D), we decode it for use in headers. + return decodeURIComponent(match[2]); + } + + // Client-side fallback: Use Better Auth client's getSession + // This is a last resort if cookies are not accessible via document.cookie (e.g. httpOnly) + try { + const { data } = await authClient.getSession(); + return data?.session?.token ?? null; + } catch { + return null; + } +} diff --git a/lib/bounty-config.ts b/lib/bounty-config.ts index 53075bb..e106e1e 100644 --- a/lib/bounty-config.ts +++ b/lib/bounty-config.ts @@ -1,7 +1,5 @@ -import type { BountyStatus, BountyType } from "@/types/bounty"; - export const STATUS_CONFIG: Record< - BountyStatus, + string, { label: string; dot: string; className: string } > = { OPEN: { @@ -47,20 +45,18 @@ export const STATUS_CONFIG: Record< }, }; -export const TYPE_CONFIG: Record< - BountyType, - { label: string; className: string } -> = { - FIXED_PRICE: { - label: "Fixed Price", - className: "bg-primary/10 text-primary border border-primary/20", - }, - MILESTONE_BASED: { - label: "Milestone", - className: "bg-violet-500/10 text-violet-400 border border-violet-500/20", - }, - COMPETITION: { - label: "Competition", - className: "bg-rose-500/10 text-rose-400 border border-rose-500/20", - }, -}; +export const TYPE_CONFIG: Record = + { + FIXED_PRICE: { + label: "Fixed Price", + className: "bg-primary/10 text-primary border border-primary/20", + }, + MILESTONE_BASED: { + label: "Milestone", + className: "bg-violet-500/10 text-violet-400 border border-violet-500/20", + }, + COMPETITION: { + label: "Competition", + className: "bg-rose-500/10 text-rose-400 border border-rose-500/20", + }, + }; diff --git a/lib/graphql/client.ts b/lib/graphql/client.ts index 126622e..762c7a4 100644 --- a/lib/graphql/client.ts +++ b/lib/graphql/client.ts @@ -2,6 +2,8 @@ import { GraphQLClient } from "graphql-request"; import { isAuthStatus } from "./errors"; +import { toast } from "sonner"; +import { getAccessToken } from "../auth-utils"; // Re-export all error utilities from errors.ts for convenience export { @@ -15,32 +17,19 @@ export { type GraphQLErrorResponse, } from "./errors"; -// In-memory token storage (XSS-resistant, lost on page refresh) -let accessToken: string | null = null; - -export function getAccessToken(): string | null { - if (typeof window === "undefined") return null; - return accessToken; -} - -export function setAccessToken(token: string): void { - if (typeof window === "undefined") return; - accessToken = token; -} - -export function clearAccessToken(): void { - if (typeof window === "undefined") return; - accessToken = null; -} - -export function hasAccessToken(): boolean { - if (typeof window === "undefined") return false; - return accessToken !== null; +/** + * Checks if a session exists. + */ +export async function hasAccessToken(): Promise { + const token = await getAccessToken(); + return token !== null; } // Create the generic GraphQLClient instance const url = process.env.NEXT_PUBLIC_GRAPHQL_URL || "/api/graphql"; -export const graphQLClient = new GraphQLClient(url); +export const graphQLClient = new GraphQLClient(url, { + credentials: "include", +}); // A custom fetcher for @graphql-codegen/typescript-react-query export const fetcher = < @@ -51,7 +40,7 @@ export const fetcher = < variables?: TVariables, ) => { return async (): Promise => { - const token = getAccessToken(); + const token = await getAccessToken(); const headers: Record = {}; if (token) { headers.authorization = `Bearer ${token}`; @@ -66,7 +55,7 @@ export const fetcher = < ) => Promise )(query, variables, headers); } catch (error: unknown) { - // Global error handling for auth failures (like Apollo ErrorLink) + // Global error handling for auth failures const gqlError = error as { response?: { errors?: Array<{ extensions?: { status?: number } }> }; }; @@ -74,8 +63,9 @@ export const fetcher = < gqlError.response.errors.forEach((err) => { const status = err?.extensions?.status ?? 500; if (isAuthStatus(status)) { - clearAccessToken(); + // Let the application handle unauthorized state, potentially redirecting to login if (typeof window !== "undefined") { + toast.error("Your session has expired. Please log in again."); window.dispatchEvent( new CustomEvent("auth:unauthorized", { detail: { status } }), ); diff --git a/lib/graphql/generated.ts b/lib/graphql/generated.ts index 3410cc5..9b56b33 100644 --- a/lib/graphql/generated.ts +++ b/lib/graphql/generated.ts @@ -284,6 +284,7 @@ export type CreateBountyMutation = { bountyWindowId?: string | null; githubIssueUrl: string; githubIssueNumber?: number | null; + createdBy: string; organization?: { __typename?: "BountyOrganization"; id: string; @@ -331,6 +332,7 @@ export type UpdateBountyMutation = { bountyWindowId?: string | null; githubIssueUrl: string; githubIssueNumber?: number | null; + createdBy: string; organization?: { __typename?: "BountyOrganization"; id: string; @@ -392,6 +394,7 @@ export type BountiesQuery = { bountyWindowId?: string | null; githubIssueUrl: string; githubIssueNumber?: number | null; + createdBy: string; organization?: { __typename?: "BountyOrganization"; id: string; @@ -440,6 +443,7 @@ export type BountyQuery = { bountyWindowId?: string | null; githubIssueUrl: string; githubIssueNumber?: number | null; + createdBy: string; submissions?: Array<{ __typename?: "BountySubmissionType"; id: string; @@ -512,6 +516,7 @@ export type ActiveBountiesQuery = { bountyWindowId?: string | null; githubIssueUrl: string; githubIssueNumber?: number | null; + createdBy: string; organization?: { __typename?: "BountyOrganization"; id: string; @@ -559,6 +564,7 @@ export type OrganizationBountiesQuery = { bountyWindowId?: string | null; githubIssueUrl: string; githubIssueNumber?: number | null; + createdBy: string; organization?: { __typename?: "BountyOrganization"; id: string; @@ -606,6 +612,7 @@ export type ProjectBountiesQuery = { bountyWindowId?: string | null; githubIssueUrl: string; githubIssueNumber?: number | null; + createdBy: string; organization?: { __typename?: "BountyOrganization"; id: string; @@ -647,6 +654,7 @@ export type BountyFieldsFragment = { bountyWindowId?: string | null; githubIssueUrl: string; githubIssueNumber?: number | null; + createdBy: string; organization?: { __typename?: "BountyOrganization"; id: string; @@ -851,6 +859,7 @@ export const BountyFieldsFragmentDoc = ` bountyWindowId githubIssueUrl githubIssueNumber + createdBy organization { id name diff --git a/lib/graphql/operations/fragments.graphql b/lib/graphql/operations/fragments.graphql index e675d1e..ef4b067 100644 --- a/lib/graphql/operations/fragments.graphql +++ b/lib/graphql/operations/fragments.graphql @@ -13,6 +13,7 @@ fragment BountyFields on Bounty { bountyWindowId githubIssueUrl githubIssueNumber + createdBy organization { id name diff --git a/lib/query/bounty-queries.ts b/lib/query/bounty-queries.ts index 8059f78..3296079 100644 --- a/lib/query/bounty-queries.ts +++ b/lib/query/bounty-queries.ts @@ -1,49 +1,103 @@ -import { queryOptions, infiniteQueryOptions } from '@tanstack/react-query'; -import { bountiesApi, type Bounty, type BountyListParams, type PaginatedResponse } from '@/lib/api'; -import { bountyKeys } from './query-keys'; +import { queryOptions, infiniteQueryOptions } from "@tanstack/react-query"; +import { fetcher } from "@/lib/graphql/client"; +import { + BountiesDocument, + BountyDocument, + type BountiesQuery, + type BountyQuery, + type BountyQueryInput, + type BountyFieldsFragment, +} from "@/lib/graphql/generated"; +import { type PaginatedResponse } from "@/lib/api/types"; +import { bountyKeys } from "./query-keys"; const DEFAULT_LIMIT = 20; /** * Query options factory for bounty list */ -export function bountyListQueryOptions(params?: BountyListParams) { - return queryOptions>({ - queryKey: bountyKeys.list(params), - queryFn: () => bountiesApi.list(params), - }); +export function bountyListQueryOptions(params?: BountyQueryInput) { + return queryOptions>({ + queryKey: bountyKeys.list(params), + queryFn: async () => { + const response = await fetcher< + BountiesQuery, + { query: BountyQueryInput } + >(BountiesDocument, { query: params ?? {} })(); + const data = response.bounties; + return { + data: data.bounties as BountyFieldsFragment[], + pagination: { + page: params?.page ?? 1, + limit: data.limit, + total: data.total, + totalPages: Math.ceil(data.total / data.limit), + }, + }; + }, + }); } /** * Query options factory for single bounty */ export function bountyDetailQueryOptions(id: string) { - return queryOptions({ - queryKey: bountyKeys.detail(id), - queryFn: () => bountiesApi.getById(id), - enabled: !!id, - }); + return queryOptions({ + queryKey: bountyKeys.detail(id), + queryFn: async () => { + const response = await fetcher( + BountyDocument, + { id }, + )(); + return response.bounty as BountyFieldsFragment; + }, + enabled: !!id, + }); } /** * Infinite query options for bounty pagination */ -export function bountyInfiniteQueryOptions(params?: Omit) { - return infiniteQueryOptions>({ - queryKey: bountyKeys.infinite(params), - queryFn: ({ pageParam }) => - bountiesApi.list({ ...params, page: pageParam as number, limit: params?.limit ?? DEFAULT_LIMIT }), - initialPageParam: 1, - getNextPageParam: (lastPage) => { - const { page, totalPages } = lastPage.pagination; - return page < totalPages ? page + 1 : undefined; +export function bountyInfiniteQueryOptions( + params?: Omit, +) { + return infiniteQueryOptions>({ + queryKey: bountyKeys.infinite(params), + queryFn: async ({ pageParam }) => { + const response = await fetcher< + BountiesQuery, + { query: BountyQueryInput } + >(BountiesDocument, { + query: { + ...params, + page: pageParam as number, + limit: params?.limit ?? DEFAULT_LIMIT, + }, + })(); + const data = response.bounties; + return { + data: data.bounties as BountyFieldsFragment[], + pagination: { + page: pageParam as number, + limit: data.limit, + total: data.total, + totalPages: Math.ceil(data.total / data.limit), }, - }); + }; + }, + initialPageParam: 1, + getNextPageParam: (lastPage: PaginatedResponse) => { + const { page, totalPages } = lastPage.pagination; + return page < totalPages ? page + 1 : undefined; + }, + }); } /** * Helper to flatten infinite query pages */ -export function flattenBountyPages(pages: PaginatedResponse[] | undefined): Bounty[] { - return pages?.flatMap((page) => page.data) ?? []; +export function flattenBountyPages( + pages: PaginatedResponse[] | undefined, +): BountyFieldsFragment[] { + return pages?.flatMap((page) => page.data) ?? []; } diff --git a/lib/query/prefetch.ts b/lib/query/prefetch.ts index 8d7dc1f..f6fc44e 100644 --- a/lib/query/prefetch.ts +++ b/lib/query/prefetch.ts @@ -1,48 +1,51 @@ -import { QueryClient } from '@tanstack/react-query'; -import type { BountyListParams } from '@/lib/api'; -import { bountyListQueryOptions, bountyDetailQueryOptions } from './bounty-queries'; +import { QueryClient } from "@tanstack/react-query"; +import { type BountyQueryInput } from "@/lib/graphql/generated"; +import { + bountyListQueryOptions, + bountyDetailQueryOptions, +} from "./bounty-queries"; /** * Create a QueryClient for server components * Each request should create a new instance to avoid sharing state */ export function createQueryClient(): QueryClient { - return new QueryClient({ - defaultOptions: { - queries: { - staleTime: 60 * 1000, - }, - }, - }); + return new QueryClient({ + defaultOptions: { + queries: { + staleTime: 60 * 1000, + }, + }, + }); } /** * Prefetch bounty list for server components */ export async function prefetchBountyList( - queryClient: QueryClient, - params?: BountyListParams + queryClient: QueryClient, + params?: BountyQueryInput, ): Promise { - await queryClient.prefetchQuery(bountyListQueryOptions(params)); + await queryClient.prefetchQuery(bountyListQueryOptions(params)); } /** * Prefetch single bounty for server components */ export async function prefetchBounty( - queryClient: QueryClient, - id: string + queryClient: QueryClient, + id: string, ): Promise { - if (!id) return; - await queryClient.prefetchQuery(bountyDetailQueryOptions(id)); + if (!id) return; + await queryClient.prefetchQuery(bountyDetailQueryOptions(id)); } /** * Prefetch multiple bounties by ID (for list pages with detail prefetch) */ export async function prefetchBounties( - queryClient: QueryClient, - ids: string[] + queryClient: QueryClient, + ids: string[], ): Promise { - await Promise.all(ids.map((id) => prefetchBounty(queryClient, id))); + await Promise.all(ids.map((id) => prefetchBounty(queryClient, id))); } diff --git a/lib/query/query-keys.ts b/lib/query/query-keys.ts index 6343804..f9c7602 100644 --- a/lib/query/query-keys.ts +++ b/lib/query/query-keys.ts @@ -1,53 +1,47 @@ -import type { BountyListParams } from '@/lib/api'; +import { useBountyQuery, type BountyQueryInput } from "@/lib/graphql/generated"; /** * Query Key Factory for Bounties - * - * Hierarchical structure enables granular cache invalidation: - * - bountyKeys.all → invalidates everything - * - bountyKeys.lists() → invalidates all lists, keeps details - * - bountyKeys.list(filters) → invalidates specific filtered list - * - bountyKeys.details() → invalidates all details, keeps lists - * - bountyKeys.detail(id) → invalidates specific bounty */ export const bountyKeys = { - all: ['bounties'] as const, - lists: () => [...bountyKeys.all, 'list'] as const, - list: (filters?: BountyListParams) => [...bountyKeys.lists(), filters] as const, - infinite: (filters?: Omit) => [...bountyKeys.lists(), 'infinite', filters] as const, - details: () => [...bountyKeys.all, 'detail'] as const, - detail: (id: string) => [...bountyKeys.details(), id] as const, + all: ["Bounties"] as const, + lists: () => [...bountyKeys.all, "lists"] as const, + list: (params?: BountyQueryInput) => + [...bountyKeys.lists(), { query: params }] as const, + infinite: (params?: Omit) => + [...bountyKeys.lists(), "infinite", { query: params }] as const, + detail: (id: string) => useBountyQuery.getKey({ id }), }; // Type helpers for query keys export type BountyQueryKey = - | ReturnType - | ReturnType - | ReturnType; + | ReturnType + | ReturnType + | ReturnType; /** * Query Key Factory for Authentication - * + * * Hierarchical structure for auth/user cache management: * - authKeys.all → invalidates everything auth-related * - authKeys.session() → invalidates session data */ export const authKeys = { - all: ['auth'] as const, - session: () => [...authKeys.all, 'session'] as const, + all: ["auth"] as const, + session: () => [...authKeys.all, "session"] as const, }; export const complianceKeys = { - all: ['compliance'] as const, - status: () => [...complianceKeys.all, 'status'] as const, + all: ["compliance"] as const, + status: () => [...complianceKeys.all, "status"] as const, }; export const termsKeys = { - all: ['terms'] as const, - current: () => [...termsKeys.all, 'current'] as const, + all: ["terms"] as const, + current: () => [...termsKeys.all, "current"] as const, }; export const withdrawalKeys = { - all: ['withdrawal'] as const, - history: () => [...withdrawalKeys.all, 'history'] as const, + all: ["withdrawal"] as const, + history: () => [...withdrawalKeys.all, "history"] as const, }; diff --git a/lib/query/sync/handlers.ts b/lib/query/sync/handlers.ts index d1c98a0..d3b1424 100644 --- a/lib/query/sync/handlers.ts +++ b/lib/query/sync/handlers.ts @@ -1,12 +1,16 @@ -import { QueryClient } from '@tanstack/react-query'; -import { Bounty, PaginatedResponse } from '@/lib/api'; -import { bountyKeys } from '@/hooks/use-bounties'; +import { QueryClient } from "@tanstack/react-query"; +import { type BountyFieldsFragment } from "@/lib/graphql/generated"; +import { type PaginatedResponse } from "@/lib/api/types"; +import { bountyKeys } from "@/hooks/use-bounties"; -export function handleBountyCreated(queryClient: QueryClient, bounty: Bounty) { - console.log('[Sync] Handling bounty.created:', bounty.id); +export function handleBountyCreated( + queryClient: QueryClient, + bounty: BountyFieldsFragment, +) { + console.log("[Sync] Handling bounty.created:", bounty.id); // Update lists - queryClient.setQueriesData>( + queryClient.setQueriesData>( { queryKey: bountyKeys.lists() }, (oldData) => { if (!oldData) return oldData; @@ -18,18 +22,21 @@ export function handleBountyCreated(queryClient: QueryClient, bounty: Bounty) { total: oldData.pagination.total + 1, }, }; - } + }, ); // Set detail cache queryClient.setQueryData(bountyKeys.detail(bounty.id), bounty); } -export function handleBountyUpdated(queryClient: QueryClient, bounty: Bounty) { - console.log('[Sync] Handling bounty.updated:', bounty.id); +export function handleBountyUpdated( + queryClient: QueryClient, + bounty: BountyFieldsFragment, +) { + console.log("[Sync] Handling bounty.updated:", bounty.id); // Update lists - queryClient.setQueriesData>( + queryClient.setQueriesData>( { queryKey: bountyKeys.lists() }, (oldData) => { if (!oldData) return oldData; @@ -37,18 +44,21 @@ export function handleBountyUpdated(queryClient: QueryClient, bounty: Bounty) { ...oldData, data: oldData.data.map((b) => (b.id === bounty.id ? bounty : b)), }; - } + }, ); // Update detail cache queryClient.setQueryData(bountyKeys.detail(bounty.id), bounty); } -export function handleBountyDeleted(queryClient: QueryClient, bountyId: string) { - console.log('[Sync] Handling bounty.deleted:', bountyId); +export function handleBountyDeleted( + queryClient: QueryClient, + bountyId: string, +) { + console.log("[Sync] Handling bounty.deleted:", bountyId); // Update lists - queryClient.setQueriesData>( + queryClient.setQueriesData>( { queryKey: bountyKeys.lists() }, (oldData) => { if (!oldData) return oldData; @@ -60,7 +70,7 @@ export function handleBountyDeleted(queryClient: QueryClient, bountyId: string) total: Math.max(0, oldData.pagination.total - 1), }, }; - } + }, ); // Invalidate or remove detail cache diff --git a/lib/server-auth.ts b/lib/server-auth.ts index 33252a6..2b14083 100644 --- a/lib/server-auth.ts +++ b/lib/server-auth.ts @@ -1,28 +1,109 @@ -import { cookies } from "next/headers"; +import { getSessionCookie } from "better-auth/cookies"; +import { headers } from "next/headers"; export interface User { - id: string; - name: string; - email?: string; + id: string; + name: string; + email?: string; + image?: string; } +interface SessionUser { + id?: string; + name?: string | null; + email?: string | null; + image?: string | null; +} + +interface SessionPayload { + user?: SessionUser | null; + session?: { + token?: string | null; + } | null; +} + +async function fetchValidatedSession( + cookieHeader: string, + sessionToken: string, +): Promise { + const baseURL = process.env.NEXT_PUBLIC_API_URL; + if (!baseURL) { + console.error("NEXT_PUBLIC_API_URL is not set; cannot validate session."); + return null; + } + + const endpoints = ["/api/auth/get-session", "/api/auth/session"]; + + for (const endpoint of endpoints) { + try { + const url = new URL(endpoint, baseURL); + const response = await fetch(url, { + method: "GET", + headers: { + cookie: cookieHeader, + authorization: `Bearer ${sessionToken}`, + accept: "application/json", + }, + cache: "no-store", + }); + + if (!response.ok) { + if (response.status === 401 || response.status === 403) { + return null; + } + continue; + } + + const payload: unknown = await response.json(); + const normalized: SessionPayload | undefined = + typeof payload === "object" && payload !== null && "data" in payload + ? (payload as { data?: SessionPayload }).data + : (payload as SessionPayload); + + if (normalized?.user?.id) { + return normalized; + } + } catch (error) { + console.error(`Failed to validate session via ${endpoint}:`, error); + } + } + + return null; +} + +/** + * Gets the current user from the session cookie. + * This can be used in Server Components and Server Actions. + */ export async function getCurrentUser(): Promise { - // In a real implementation, this would use the better-auth server instance: - // const session = await auth.api.getSession({ headers: await headers() }); - // return session?.user; - - // For now, checks for the cookie used in proxy.ts as a weak signal, - // or returns a mock user in development. - const cookieStore = await cookies(); - const sessionCookie = cookieStore.get("boundless_auth.session_token") || cookieStore.get("boundless_auth"); - - if (process.env.NODE_ENV === "development" || sessionCookie) { - return { - id: "mock-user-123", - name: "Mock User", - email: "mock@example.com" - }; + const requestHeaders = await headers(); + const sessionCookie = getSessionCookie(requestHeaders, { + cookiePrefix: "boundless_auth", + }); + + if (!sessionCookie) { + return null; + } + + try { + const cookieHeader = requestHeaders.get("cookie") ?? ""; + const validatedSession = await fetchValidatedSession( + cookieHeader, + sessionCookie, + ); + + if (!validatedSession?.user?.id) { + return null; } + return { + id: validatedSession.user.id, + name: validatedSession.user.name ?? "Authenticated User", + email: validatedSession.user.email ?? undefined, + image: validatedSession.user.image ?? undefined, + }; + } catch (error) { + console.error("Failed to resolve current user from session:", error); return null; + } }