From 116df387dee75820d6508fac9e0b1fabb86c07f7 Mon Sep 17 00:00:00 2001 From: Ken Chambers Date: Wed, 18 Feb 2026 06:23:20 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20implement=20GET=20/api/bounties/:id=20?= =?UTF-8?q?=E2=80=94=20bounty=20detail=20endpoint?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #21 ## Summary Implements the GET /api/bounties/:id endpoint to return full bounty details including creator info, application count, assignee info, and current status. ### Backend (packages/api/src/index.ts) - Added GET /api/bounties/:id route to the Hono server - Proxies to the main DevAsign API (DEVASIGN_API_URL env var) - Forwards Authorization header for authenticated requests - Returns structured 404 when bounty not found - Returns 502 on upstream API failure with descriptive error - Input validated: rejects empty/missing IDs ### Types (packages/mobile/types.ts) - Added optional applicationCount?: number to Bounty interface - Added optional assignee?: { username, avatarUrl } | null to Bounty to represent the assigned developer if one exists ### Frontend (packages/mobile/pages/BountyDetail.tsx) - Replaced MOCK_BOUNTIES.find() with real fetch() to /api/bounties/:id - Added loading and error states for async data fetching - AbortController cleanup on component unmount - Shows applicationCount with pluralisation (e.g. '3 applications') - Shows assignee card with avatar and username when bounty is assigned - Shows 'Unassigned / Open for applications' when status is Open and no assignee exists - 'Apply for Bounty' CTA now only renders for Open, unassigned bounties ### Environment - DEVASIGN_API_URL: upstream API base URL (default: https://api.devasign.com) --- packages/api/src/index.ts | 157 +++++++++++++ packages/mobile/pages/BountyDetail.tsx | 305 +++++++++++++++++++++++++ packages/mobile/types.ts | 68 ++++++ 3 files changed, 530 insertions(+) create mode 100644 packages/api/src/index.ts create mode 100644 packages/mobile/pages/BountyDetail.tsx create mode 100644 packages/mobile/types.ts diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts new file mode 100644 index 0000000..f7a9505 --- /dev/null +++ b/packages/api/src/index.ts @@ -0,0 +1,157 @@ +import { serve } from '@hono/node-server'; +import { Hono } from 'hono'; +import { cors } from 'hono/cors'; +import { logger } from 'hono/logger'; +import dotenv from 'dotenv'; + +// Load environment variables +dotenv.config(); + +// Validate environment variables +if (!process.env.GEMINI_API_KEY) { + console.error('FATAL ERROR: GEMINI_API_KEY is not defined in the environment.'); + process.exit(1); +} + +const app = new Hono(); +const port = Number(process.env.PORT) || 3001; + +// The base URL of the main DevAsign API. Configurable via environment variable. +const DEVASIGN_API_URL = process.env.DEVASIGN_API_URL || 'https://api.devasign.com'; + +// Global middleware +app.use('*', logger()); +app.use('*', cors()); + +// Rate limiter stub middleware +app.use('*', async (_c, next) => { + // TODO(#1): Implement a robust rate limiter (e.g., using `@hono/rate-limiter`). + // For now, checks are skipped + await next(); +}); + +// Error handler +app.onError((err, c) => { + console.error('App Error:', err); + if (process.env.NODE_ENV === 'production') { + return c.json({ error: 'Internal server error' }, 500); + } + return c.json({ error: 'Internal server error', message: err.message }, 500); +}); + +// API Routes +app.get('/health', (c) => { + return c.json({ status: 'ok' }); +}); + +app.post('/api/gemini', async (c) => { + try { + const apiKey = process.env.GEMINI_API_KEY; + + if (!apiKey) { + return c.json({ error: 'Gemini API key not configured on server' }, 500); + } + + // This is where the actual Gemini API call would go. + // For now, we'll just return a success message indicating the secure setup works. + // In a real implementation, you would use the Google Generative AI SDK here. + + const body = await c.req.json(); + const { prompt } = body; + + if (typeof prompt !== 'string' || prompt.trim() === '') { + return c.json({ error: 'Prompt is required and must be a non-empty string' }, 400); + } + + console.log('Received prompt:', prompt); + + return c.json({ + message: 'Request received securely on backend', + status: 'success' + }); + + } catch (error: any) { + console.error('Error processing Gemini request:', error); + return c.json({ error: 'Internal server error' }, 500); + } +}); + +/** + * GET /api/bounties/:id + * + * Fetches full details for a single bounty, including: + * - Creator info (username, avatarUrl) + * - Application count + * - Assignee info (if the bounty has been assigned to a developer) + * - Current status + * + * Proxies to the main DevAsign API at DEVASIGN_API_URL. + * Returns 404 if the bounty is not found, 502 if the upstream API is unreachable. + */ +app.get('/api/bounties/:id', async (c) => { + const { id } = c.req.param(); + + if (!id || id.trim() === '') { + return c.json({ error: 'Bounty ID is required' }, 400); + } + + try { + const upstreamUrl = `${DEVASIGN_API_URL}/bounties/${encodeURIComponent(id)}`; + + const response = await fetch(upstreamUrl, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + // Forward the Authorization header if present (authenticated requests) + ...(c.req.header('Authorization') + ? { Authorization: c.req.header('Authorization')! } + : {}), + }, + }); + + if (response.status === 404) { + return c.json({ error: 'Bounty not found' }, 404); + } + + if (!response.ok) { + console.error(`Upstream API error: ${response.status} ${response.statusText}`); + return c.json( + { error: 'Failed to fetch bounty details from upstream API' }, + 502 + ); + } + + const data = await response.json() as { + id: string; + repoOwner: string; + repoName: string; + title: string; + description: string; + amount: number; + tags: string[]; + difficulty: string; + deadline: string; + status: string; + creator: { + username: string; + avatarUrl: string; + rating: number; + }; + requirements: string[]; + applicationCount: number; + assignee: { username: string; avatarUrl: string } | null; + }; + + return c.json(data); + } catch (error: any) { + console.error(`Error fetching bounty ${id}:`, error); + return c.json({ error: 'Unable to reach upstream API' }, 502); + } +}); + +console.log(`Server is running on http://localhost:${port}`); + +serve({ + fetch: app.fetch, + port +}); diff --git a/packages/mobile/pages/BountyDetail.tsx b/packages/mobile/pages/BountyDetail.tsx new file mode 100644 index 0000000..ee261b5 --- /dev/null +++ b/packages/mobile/pages/BountyDetail.tsx @@ -0,0 +1,305 @@ +import React, { useState, useEffect } from 'react'; +import { useParams, useNavigate, useLocation } from 'react-router-dom'; +import { ArrowLeft, Github, Calendar, Shield, Share2, CheckCircle2, Users, User as UserIcon } from 'lucide-react'; +import { Button, Badge } from '../components/Shared'; +import { Bounty } from '../types'; + +export const BountyDetail: React.FC = () => { + const { id } = useParams(); + const navigate = useNavigate(); + const location = useLocation(); + const [bounty, setBounty] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [applying, setApplying] = useState(false); + const [submitted, setSubmitted] = useState(false); + + // Check if we should hide the apply button (e.g. user came from Submit Task page) + const hideApply = (location.state as { hideApply?: boolean })?.hideApply; + + useEffect(() => { + if (!id) { + setError('No bounty ID provided.'); + setLoading(false); + return; + } + + const controller = new AbortController(); + + const fetchBounty = async () => { + setLoading(true); + setError(null); + try { + const response = await fetch(`/api/bounties/${encodeURIComponent(id)}`, { + signal: controller.signal, + }); + + if (response.status === 404) { + setError('Bounty not found.'); + return; + } + + if (!response.ok) { + throw new Error(`Server error: ${response.status}`); + } + + const data: Bounty = await response.json(); + setBounty(data); + } catch (err: any) { + if (err.name !== 'AbortError') { + console.error('Failed to fetch bounty:', err); + setError('Failed to load bounty details. Please try again.'); + } + } finally { + setLoading(false); + } + }; + + fetchBounty(); + + return () => controller.abort(); + }, [id]); + + if (loading) { + return ( +
+ Loading bounty details… +
+ ); + } + + if (error || !bounty) { + return ( +
+ {error ?? 'Bounty not found'} +
+ ); + } + + if (submitted) { + return ( +
+
+ +
+
+

Application Submitted!

+

+ The bounty creator will review your application. You'll be notified once accepted. +

+
+
+ + +
+
+ ); + } + + if (applying) { + return ( +
+
+ +

Apply for Bounty

+
+ +
+
APPLYING FOR
+
{bounty.title}
+
${bounty.amount.toLocaleString()} USDC
+
+ +
{ e.preventDefault(); setSubmitted(true); }}> +
+ + +
+ +
+ +
+ +
+
+
+ +
+ + +
+ +
+ +
+
+
+ ); + } + + return ( +
+ {/* Sticky Navbar Actions */} +
+ +
+ +
+
+ +
+ {/* Header Info */} +
+
+ + {bounty.repoOwner}/{bounty.repoName} +
+ +

{bounty.title}

+ +
+
+
Bounty Amount
+
${bounty.amount.toLocaleString()}
+
+
+
Difficulty
+ {bounty.difficulty} +
+
+
+ + {/* Tags */} +
+ {bounty.tags.map(tag => ( + + {tag} + + ))} +
+ + {/* Meta Row: Creator, Applications, Assignee */} +
+ {/* Creator */} +
+ {bounty.creator.username} +
+
@{bounty.creator.username}
+
Bounty Creator • {bounty.creator.rating} ★
+
+
+ + {/* Application Count */} + {bounty.applicationCount !== undefined && ( +
+
+ +
+
+
+ {bounty.applicationCount === 0 + ? 'No applications yet' + : `${bounty.applicationCount} application${bounty.applicationCount !== 1 ? 's' : ''}`} +
+
Be the first to apply!
+
+
+ )} + + {/* Assignee */} + {bounty.assignee ? ( +
+ {bounty.assignee.username} +
+
@{bounty.assignee.username}
+
Assigned Developer
+
+ Assigned +
+ ) : bounty.status === 'Open' ? ( +
+
+ +
+
+
Unassigned
+
Open for applications
+
+ Open +
+ ) : null} +
+ + {/* Tabs / Sections */} +
+
+

Description

+
+

{bounty.description}

+

Here is some additional context usually provided in the markdown. The developer must ensure that the fix passes all regression tests.

+ +
+ const bug = await reproduce(true); +
+
+
+ +
+

Requirements

+
    + {bounty.requirements.map((req, i) => ( +
  • + + {req} +
  • + ))} +
+
+ +
+ + Deadline: {new Date(bounty.deadline).toLocaleDateString()} +
+
+
+ + {/* Sticky Bottom CTA */} + {!hideApply && bounty.status === 'Open' && !bounty.assignee && ( +
+ +
+ )} +
+ ); +}; diff --git a/packages/mobile/types.ts b/packages/mobile/types.ts new file mode 100644 index 0000000..4e220fc --- /dev/null +++ b/packages/mobile/types.ts @@ -0,0 +1,68 @@ +export type Difficulty = 'Beginner' | 'Intermediate' | 'Advanced'; +export type Status = 'Open' | 'Assigned' | 'In Review' | 'Completed' | 'Cancelled'; +export type ApplicationStatus = 'Pending' | 'Accepted' | 'Rejected'; +export type SubmissionStatus = 'Pending' | 'Approved' | 'Rejected' | 'Disputed'; + +export interface User { + id: string; + username: string; + avatarUrl: string; + totalEarned: number; + bountiesCompleted: number; + successRate: number; + techStack: string[]; + walletAddress: string; +} + +export interface Bounty { + id: string; + repoOwner: string; + repoName: string; + title: string; + description: string; + amount: number; + tags: string[]; + difficulty: Difficulty; + deadline: string; // ISO date string + status: Status; + creator: { + username: string; + avatarUrl: string; + rating: number; + }; + requirements: string[]; + /** Number of applications submitted for this bounty */ + applicationCount?: number; + /** Developer assigned to work on this bounty, if any */ + assignee?: { + username: string; + avatarUrl: string; + } | null; +} + +export interface Application { + id: string; + bountyId: string; + status: ApplicationStatus; + appliedAt: string; +} + +export interface Transaction { + id: string; + type: 'Earning' | 'Withdrawal'; + amount: number; + date: string; + status: 'Completed' | 'Pending' | 'Failed'; + description: string; +} + +export interface Task { + id: string; + title: string; + amount: number; + status: string; + repo: string; + deadline?: string; + submittedAt?: string; + completedAt?: string; +}