diff --git a/frontend/src/app/(dashboard)/bounties/page.tsx b/frontend/src/app/(dashboard)/bounties/page.tsx new file mode 100644 index 0000000..317c95b --- /dev/null +++ b/frontend/src/app/(dashboard)/bounties/page.tsx @@ -0,0 +1,27 @@ +"use client"; + +import React from "react"; +import { BountyFilters } from "@/components/BountyFilters"; +import { BountyList } from "@/components/BountyList"; +import { useBountyDiscovery } from "@/hooks/useBountyDiscovery"; + +export default function BountiesPage() { + const { tasks, pagination, isLoading, error, filters, setFilters, resetFilters, setPage } = + useBountyDiscovery(); + + return ( +
+

Discover Bounties

+ + + + +
+ ); +} diff --git a/frontend/src/app/api/tasks/route.ts b/frontend/src/app/api/tasks/route.ts index f0d5b09..5746c85 100644 --- a/frontend/src/app/api/tasks/route.ts +++ b/frontend/src/app/api/tasks/route.ts @@ -1,10 +1,64 @@ -import { createTask } from "@/lib/task-workflow"; +import { createTask, listTasks } from "@/lib/task-workflow"; import { buildNoStoreJson } from "@/lib/api-response"; import { checkRateLimit } from "@/lib/rate-limit"; +import type { TaskDifficulty, TaskSortOrder } from "@/types/task-workflow"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; +const VALID_DIFFICULTIES: TaskDifficulty[] = ["beginner", "intermediate", "advanced"]; +const VALID_SORTS: TaskSortOrder[] = ["newest", "reward_desc", "deadline_asc"]; + +function parseOptionalNumber(value: string | null): number | undefined { + if (!value) return undefined; + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : undefined; +} + +export async function GET(request: Request) { + const { response: rateLimitResponse, headers: rateLimitHeaders } = + checkRateLimit(request); + if (rateLimitResponse) { + return rateLimitResponse; + } + + const { searchParams } = new URL(request.url); + + const difficultyParam = searchParams.get("difficulty"); + const sortParam = searchParams.get("sort"); + + const result = listTasks({ + search: searchParams.get("search") ?? undefined, + minReward: parseOptionalNumber(searchParams.get("minReward")), + maxReward: parseOptionalNumber(searchParams.get("maxReward")), + difficulty: VALID_DIFFICULTIES.includes(difficultyParam as TaskDifficulty) + ? (difficultyParam as TaskDifficulty) + : undefined, + technology: searchParams.get("technology") ?? undefined, + organization: searchParams.get("organization") ?? undefined, + sort: VALID_SORTS.includes(sortParam as TaskSortOrder) + ? (sortParam as TaskSortOrder) + : undefined, + page: parseOptionalNumber(searchParams.get("page")), + pageSize: parseOptionalNumber(searchParams.get("pageSize")), + }); + + return buildNoStoreJson( + { + ok: true, + tasks: result.tasks, + pagination: { + page: result.page, + pageSize: result.pageSize, + total: result.total, + totalPages: result.totalPages, + }, + }, + 200, + rateLimitHeaders, + ); +} + export async function POST(request: Request) { const { response: rateLimitResponse, headers: rateLimitHeaders } = checkRateLimit(request); @@ -40,6 +94,10 @@ export async function POST(request: Request) { } const payload = body as Record; + const difficulty = VALID_DIFFICULTIES.includes(payload.difficulty as TaskDifficulty) + ? (payload.difficulty as TaskDifficulty) + : undefined; + const result = createTask({ poster: String(payload.poster ?? ""), title: String(payload.title ?? ""), @@ -47,6 +105,11 @@ export async function POST(request: Request) { reward: Number(payload.reward), deadline: Number(payload.deadline), maxSubmissions: Number(payload.maxSubmissions), + difficulty, + technologies: Array.isArray(payload.technologies) + ? payload.technologies.map((tech) => String(tech)) + : undefined, + organization: payload.organization ? String(payload.organization) : undefined, }); if (!result.ok) { diff --git a/frontend/src/components/BountyFilters.tsx b/frontend/src/components/BountyFilters.tsx new file mode 100644 index 0000000..7d3b544 --- /dev/null +++ b/frontend/src/components/BountyFilters.tsx @@ -0,0 +1,178 @@ +"use client"; + +import React from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import type { BountyFilters as BountyFiltersState } from "@/hooks/useBountyDiscovery"; +import type { TaskDifficulty, TaskSortOrder } from "@/types/task-workflow"; + +const DIFFICULTY_OPTIONS: Array<{ value: TaskDifficulty; label: string }> = [ + { value: "beginner", label: "Beginner" }, + { value: "intermediate", label: "Intermediate" }, + { value: "advanced", label: "Advanced" }, +]; + +const SORT_OPTIONS: Array<{ value: TaskSortOrder; label: string }> = [ + { value: "newest", label: "Newest" }, + { value: "reward_desc", label: "Highest reward" }, + { value: "deadline_asc", label: "Deadline (soonest)" }, +]; + +/** Sentinel used by Select items that map back to "no filter" (empty string). */ +const ANY_VALUE = "any"; + +interface BountyFiltersProps { + filters: BountyFiltersState; + onFilterChange: (partial: Partial) => void; + onReset: () => void; +} + +export function BountyFilters({ filters, onFilterChange, onReset }: BountyFiltersProps) { + return ( +
+
+

Filter Bounties

+ + {/* Keyword search */} +
+ + onFilterChange({ search: e.target.value })} + /> +
+ +
+ {/* Min Reward */} +
+ + + onFilterChange({ minReward: e.target.value ? Number(e.target.value) : "" }) + } + /> +
+ + {/* Max Reward */} +
+ + + onFilterChange({ maxReward: e.target.value ? Number(e.target.value) : "" }) + } + /> +
+ + {/* Difficulty */} +
+ + +
+ + {/* Sort */} +
+ + +
+
+ +
+ {/* Technology */} +
+ + onFilterChange({ technology: e.target.value })} + /> +
+ + {/* Organization */} +
+ + onFilterChange({ organization: e.target.value })} + /> +
+
+ +
+ +
+
+
+ ); +} diff --git a/frontend/src/components/BountyList.tsx b/frontend/src/components/BountyList.tsx new file mode 100644 index 0000000..8f5c8af --- /dev/null +++ b/frontend/src/components/BountyList.tsx @@ -0,0 +1,116 @@ +"use client"; + +import React from "react"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import type { BountyPagination } from "@/hooks/useBountyDiscovery"; +import type { TaskRecord } from "@/types/task-workflow"; + +const DIFFICULTY_LABELS: Record = { + beginner: "Beginner", + intermediate: "Intermediate", + advanced: "Advanced", +}; + +function formatReward(stroops: number): string { + return `${(stroops / 10_000_000).toLocaleString(undefined, { + maximumFractionDigits: 2, + })} XLM`; +} + +function formatDeadline(unixSeconds: number): string { + return new Date(unixSeconds * 1000).toLocaleDateString(); +} + +interface BountyListProps { + tasks: TaskRecord[]; + pagination: BountyPagination; + isLoading: boolean; + error: string | null; + onPageChange: (page: number) => void; +} + +export function BountyList({ tasks, pagination, isLoading, error, onPageChange }: BountyListProps) { + if (error) { + return ( +
+

Couldn't load bounties

+

{error}

+
+ ); + } + + if (!isLoading && tasks.length === 0) { + return ( +
+

No bounties found

+

Try adjusting your filters to see more results

+
+ ); + } + + return ( +
+
+ {tasks.map((task) => ( + + +
+
+ {task.title} + + {task.organization ? `${task.organization} • ` : ""} + Deadline {formatDeadline(task.deadline)} + +
+ {formatReward(task.reward)} +
+
+ +

{task.description}

+
+ {DIFFICULTY_LABELS[task.difficulty]} + {task.technologies.map((tech) => ( + + {tech} + + ))} +
+
+
+ ))} +
+ + {pagination.totalPages > 1 && ( + + )} +
+ ); +} diff --git a/frontend/src/components/Navbar.tsx b/frontend/src/components/Navbar.tsx index 09eec28..2b057e7 100644 --- a/frontend/src/components/Navbar.tsx +++ b/frontend/src/components/Navbar.tsx @@ -9,6 +9,7 @@ import { getPublicKey } from "@/hooks/stellar-wallets-kit"; const NAV_ITEMS = [ { name: "Overview", href: "/user/overview" }, + { name: "Bounties", href: "/bounties" }, { name: "Groups", href: "/groups" }, { name: "Fundraising", href: "/fundraising" }, { name: "Transactions", href: "/user/transactions" }, diff --git a/frontend/src/hooks/useBountyDiscovery.ts b/frontend/src/hooks/useBountyDiscovery.ts new file mode 100644 index 0000000..51d52ec --- /dev/null +++ b/frontend/src/hooks/useBountyDiscovery.ts @@ -0,0 +1,165 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import type { + TaskDifficulty, + TaskRecord, + TaskSortOrder, +} from "@/types/task-workflow"; + +export interface BountyFilters { + search: string; + minReward: number | ""; + maxReward: number | ""; + difficulty: TaskDifficulty | ""; + technology: string; + organization: string; + sort: TaskSortOrder; +} + +export const DEFAULT_BOUNTY_FILTERS: BountyFilters = { + search: "", + minReward: "", + maxReward: "", + difficulty: "", + technology: "", + organization: "", + sort: "newest", +}; + +export interface BountyPagination { + page: number; + pageSize: number; + total: number; + totalPages: number; +} + +const DEFAULT_PAGINATION: BountyPagination = { + page: 1, + pageSize: 10, + total: 0, + totalPages: 1, +}; + +export interface UseBountyDiscoveryReturn { + tasks: TaskRecord[]; + pagination: BountyPagination; + isLoading: boolean; + error: string | null; + filters: BountyFilters; + /** Merge a partial filter update; resets pagination back to page 1. */ + setFilters: (partial: Partial) => void; + resetFilters: () => void; + setPage: (page: number) => void; +} + +const SEARCH_DEBOUNCE_MS = 300; + +function buildQueryString(filters: BountyFilters, page: number): string { + const params = new URLSearchParams(); + + if (filters.search.trim()) params.set("search", filters.search.trim()); + if (filters.minReward !== "") params.set("minReward", String(filters.minReward)); + if (filters.maxReward !== "") params.set("maxReward", String(filters.maxReward)); + if (filters.difficulty) params.set("difficulty", filters.difficulty); + if (filters.technology.trim()) params.set("technology", filters.technology.trim()); + if (filters.organization.trim()) params.set("organization", filters.organization.trim()); + if (filters.sort) params.set("sort", filters.sort); + params.set("page", String(page)); + + return params.toString(); +} + +/** + * Drives the bounty discovery page: combinable filters, keyword search + * (debounced so typing doesn't trigger a request per keystroke), sorting, + * and pagination — all resolved server-side via GET /api/tasks. + */ +export function useBountyDiscovery(): UseBountyDiscoveryReturn { + const [filters, setFiltersState] = useState(DEFAULT_BOUNTY_FILTERS); + const [debouncedSearch, setDebouncedSearch] = useState(filters.search); + const [page, setPageState] = useState(1); + const [tasks, setTasks] = useState([]); + const [pagination, setPagination] = useState(DEFAULT_PAGINATION); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + // Debounce the free-text search field only; other filters (selects, number + // inputs) apply immediately since they don't fire on every keystroke. + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedSearch(filters.search); + }, SEARCH_DEBOUNCE_MS); + return () => clearTimeout(timer); + }, [filters.search]); + + const effectiveFilters = useMemo( + () => ({ ...filters, search: debouncedSearch }), + [filters, debouncedSearch], + ); + + const queryString = useMemo( + () => buildQueryString(effectiveFilters, page), + [effectiveFilters, page], + ); + + useEffect(() => { + const controller = new AbortController(); + + async function run() { + setIsLoading(true); + setError(null); + + try { + const response = await fetch(`/api/tasks?${queryString}`, { + signal: controller.signal, + }); + const body = await response.json(); + + if (!response.ok || !body.ok) { + throw new Error(body.error ?? "Failed to load bounties."); + } + + setTasks(body.tasks); + setPagination(body.pagination); + } catch (err) { + if (controller.signal.aborted) return; + setError(err instanceof Error ? err.message : "Failed to load bounties."); + } finally { + if (!controller.signal.aborted) { + setIsLoading(false); + } + } + } + + run(); + + return () => controller.abort(); + }, [queryString]); + + const setFilters = (partial: Partial) => { + setFiltersState((prev) => ({ ...prev, ...partial })); + setPageState(1); + }; + + const resetFilters = () => { + setFiltersState(DEFAULT_BOUNTY_FILTERS); + setDebouncedSearch(""); + setPageState(1); + }; + + const setPage = (nextPage: number) => { + setPageState(Math.max(1, Math.floor(nextPage))); + }; + + return { + tasks, + pagination, + isLoading, + error, + filters, + setFilters, + resetFilters, + setPage, + }; +} diff --git a/frontend/src/lib/auth.integration.test.ts b/frontend/src/lib/auth.integration.test.ts index a42d3b3..7b8451e 100644 --- a/frontend/src/lib/auth.integration.test.ts +++ b/frontend/src/lib/auth.integration.test.ts @@ -41,10 +41,12 @@ describe("auth integration flow", () => { now += 31 * 60 * 1000; - expect(auth.accessProtectedRoute(session.sessionId).allowed).toBe(false); - expect(auth.accessProtectedRoute(session.sessionId).reason).toBe( - "session-expired", - ); + // A single call: accessProtectedRoute deletes the session as a side + // effect of detecting expiry, so a second call would see it as missing + // rather than expired. + const expiredAccess = auth.accessProtectedRoute(session.sessionId); + expect(expiredAccess.allowed).toBe(false); + expect(expiredAccess.reason).toBe("session-expired"); }); it("blocks protected routes without an active session", () => { diff --git a/frontend/src/lib/form-validation.test.ts b/frontend/src/lib/form-validation.test.ts index 1829c78..551ba34 100644 --- a/frontend/src/lib/form-validation.test.ts +++ b/frontend/src/lib/form-validation.test.ts @@ -276,28 +276,28 @@ describe("validateStellarAddress", () => { }); it("rejects address with ambiguous characters (0, O, I, L)", () => { - // Contains '0' which is invalid in base32 - const result = validateStellarAddress("GABCDEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEFGHIJKLMNOPQRSTUVWXYZ234560"); + // 56 chars (correct length) but ends in '0', which is invalid in base32 + const result = validateStellarAddress("GABCDEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEFGHIJKLMNOPQRSTUV0"); expect(result.ok).toBe(false); expect(result.error).toContain("invalid characters"); }); it("accepts a valid Stellar public key", () => { - // This is a validly-formed Stellar test address - const validKey = "GBDIT6QJ3HYH6C7OAVJ4XKZXONJ6PUVL2PVIOQHHGLK6M2S6TQXAAAAAAAA"; + // This is a validly-formed Stellar test address (56 chars) + const validKey = "GABCDEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEFGHIJKLMNOPQRSTUVW"; const result = validateStellarAddress(validKey); expect(result.ok).toBe(true); }); it("accepts a standard-format G... address of 56 chars", () => { // Generate a valid format address with only valid base32 chars - const validKey = "GABCDEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; + const validKey = "GABCDEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEFGHIJKLMNOPQRSTUVW"; const result = validateStellarAddress(validKey); expect(result.ok).toBe(true); }); it("trims surrounding whitespace before validation", () => { - const result = validateStellarAddress(" GABCDEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEFGHIJKLMNOPQRSTUVWXYZ234567 "); + const result = validateStellarAddress(" GABCDEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEFGHIJKLMNOPQRSTUVW "); expect(result.ok).toBe(true); }); }); @@ -649,7 +649,7 @@ describe("validateWorkUrl", () => { }); it("rejects URL shorter than minimum", () => { - const result = validateWorkUrl("https://a.b"); + const result = validateWorkUrl("ab"); expect(result.ok).toBe(false); }); @@ -752,7 +752,7 @@ describe("validateCreateTaskForm", () => { it("accepts valid token address", () => { const result = validateCreateTaskForm({ ...validForm, - token: "GABCDEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEFGHIJKLMNOPQRSTUVWXYZ234567", + token: "GABCDEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEFGHIJKLMNOPQRSTUVW", }); expect(result.ok).toBe(true); }); @@ -848,8 +848,7 @@ describe("validateWorkSubmissionForm", () => { it("accepts valid contributor address", () => { const result = validateWorkSubmissionForm({ ...validForm, - contributorAddress: - "GABCDEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEFGHIJKLMNOPQRSTUVWXYZ234567", + contributorAddress: "GABCDEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEFGHIJKLMNOPQRSTUVW", }); expect(result.ok).toBe(true); }); diff --git a/frontend/src/lib/security-headers.test.ts b/frontend/src/lib/security-headers.test.ts index 24189b6..05864e7 100644 --- a/frontend/src/lib/security-headers.test.ts +++ b/frontend/src/lib/security-headers.test.ts @@ -19,7 +19,7 @@ import { getNextSecurityHeadersConfig, getSecurityHeaderValue, SECURITY_HEADERS, -} from "../../../security-headers.mjs"; +} from "../../security-headers.mjs"; // --------------------------------------------------------------------------- // 1. Next.js config wiring diff --git a/frontend/src/lib/task-listing.test.ts b/frontend/src/lib/task-listing.test.ts new file mode 100644 index 0000000..7532725 --- /dev/null +++ b/frontend/src/lib/task-listing.test.ts @@ -0,0 +1,254 @@ +import { afterEach, describe, expect, it } from "vitest"; + +import { createTask, listTasks, resetTaskWorkflowStore } from "@/lib/task-workflow"; + +function futureDeadline(offsetSeconds: number) { + return Math.floor(Date.now() / 1000) + offsetSeconds; +} + +function seedTasks() { + createTask( + { + poster: "GPOSTER1", + title: "Build a Soroban escrow contract", + description: "Implement escrow logic in Rust for Soroban.", + reward: 5_000_000, + deadline: futureDeadline(86_400), + maxSubmissions: 3, + difficulty: "advanced", + technologies: ["Rust", "Soroban"], + organization: "Stellar Development Foundation", + }, + new Date("2026-01-01T00:00:00.000Z"), + ); + + createTask( + { + poster: "GPOSTER2", + title: "Design a landing page", + description: "Create a modern marketing landing page in Figma.", + reward: 2_000_000, + deadline: futureDeadline(172_800), + maxSubmissions: 2, + difficulty: "beginner", + technologies: ["Figma", "CSS"], + organization: "Acme DAO", + }, + new Date("2026-01-02T00:00:00.000Z"), + ); + + createTask( + { + poster: "GPOSTER3", + title: "Optimize React dashboard performance", + description: "Reduce re-renders in the analytics dashboard.", + reward: 8_000_000, + deadline: futureDeadline(3_600), + maxSubmissions: 1, + difficulty: "intermediate", + technologies: ["React", "TypeScript"], + organization: "Acme DAO", + }, + new Date("2026-01-03T00:00:00.000Z"), + ); +} + +describe("listTasks", () => { + afterEach(() => { + resetTaskWorkflowStore(); + }); + + it("defaults difficulty, technologies, and organization when omitted", () => { + const result = createTask({ + poster: "GPOSTER", + title: "Untitled bounty", + description: "No extra metadata supplied.", + reward: 1_000_000, + deadline: futureDeadline(3_600), + maxSubmissions: 1, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.task.difficulty).toBe("intermediate"); + expect(result.task.technologies).toEqual([]); + expect(result.task.organization).toBe(""); + }); + + it("returns all tasks with no filters, newest first by default", () => { + seedTasks(); + + const result = listTasks(); + + expect(result.total).toBe(3); + expect(result.tasks.map((t) => t.title)).toEqual([ + "Optimize React dashboard performance", + "Design a landing page", + "Build a Soroban escrow contract", + ]); + }); + + it("filters by reward range", () => { + seedTasks(); + + const result = listTasks({ minReward: 3_000_000, maxReward: 6_000_000 }); + + expect(result.tasks).toHaveLength(1); + expect(result.tasks[0].title).toBe("Build a Soroban escrow contract"); + }); + + it("filters by difficulty", () => { + seedTasks(); + + const result = listTasks({ difficulty: "beginner" }); + + expect(result.tasks).toHaveLength(1); + expect(result.tasks[0].title).toBe("Design a landing page"); + }); + + it("filters by technology (case-insensitive substring match)", () => { + seedTasks(); + + const result = listTasks({ technology: "rust" }); + + expect(result.tasks).toHaveLength(1); + expect(result.tasks[0].title).toBe("Build a Soroban escrow contract"); + }); + + it("filters by organization (case-insensitive substring match)", () => { + seedTasks(); + + const result = listTasks({ organization: "acme" }); + + expect(result.tasks).toHaveLength(2); + expect(result.tasks.map((t) => t.title).sort()).toEqual( + ["Design a landing page", "Optimize React dashboard performance"].sort(), + ); + }); + + it("searches by keyword across title and description", () => { + seedTasks(); + + const result = listTasks({ search: "dashboard" }); + + expect(result.tasks).toHaveLength(1); + expect(result.tasks[0].title).toBe("Optimize React dashboard performance"); + }); + + it("combines multiple filters with AND semantics", () => { + seedTasks(); + + const result = listTasks({ organization: "acme", difficulty: "beginner" }); + + expect(result.tasks).toHaveLength(1); + expect(result.tasks[0].title).toBe("Design a landing page"); + + const empty = listTasks({ organization: "acme", difficulty: "advanced" }); + expect(empty.tasks).toHaveLength(0); + expect(empty.total).toBe(0); + }); + + it("sorts by highest reward", () => { + seedTasks(); + + const result = listTasks({ sort: "reward_desc" }); + + expect(result.tasks.map((t) => t.reward)).toEqual([8_000_000, 5_000_000, 2_000_000]); + }); + + it("sorts by soonest deadline", () => { + seedTasks(); + + const result = listTasks({ sort: "deadline_asc" }); + + expect(result.tasks.map((t) => t.title)).toEqual([ + "Optimize React dashboard performance", + "Build a Soroban escrow contract", + "Design a landing page", + ]); + }); + + it("paginates results and reports correct metadata", () => { + seedTasks(); + + const firstPage = listTasks({ pageSize: 2, page: 1 }); + expect(firstPage.tasks).toHaveLength(2); + expect(firstPage.total).toBe(3); + expect(firstPage.totalPages).toBe(2); + expect(firstPage.page).toBe(1); + + const secondPage = listTasks({ pageSize: 2, page: 2 }); + expect(secondPage.tasks).toHaveLength(1); + expect(secondPage.page).toBe(2); + + const combinedTitles = [...firstPage.tasks, ...secondPage.tasks].map((t) => t.id).sort(); + expect(combinedTitles).toEqual(["1", "2", "3"]); + }); + + it("clamps page above the last page down to totalPages", () => { + seedTasks(); + + const result = listTasks({ pageSize: 2, page: 999 }); + + expect(result.page).toBe(2); + expect(result.tasks).toHaveLength(1); + }); + + it("clamps pageSize to the maximum allowed", () => { + seedTasks(); + + const result = listTasks({ pageSize: 10_000 }); + + expect(result.pageSize).toBe(50); + }); + + it("treats non-positive page/pageSize as defaults instead of throwing", () => { + seedTasks(); + + const result = listTasks({ page: 0, pageSize: -5 }); + + expect(result.page).toBe(1); + expect(result.pageSize).toBeGreaterThan(0); + }); + + it("returns an empty page (not an error) with totalPages 1 when nothing matches", () => { + seedTasks(); + + const result = listTasks({ search: "nonexistent-keyword-xyz" }); + + expect(result.tasks).toEqual([]); + expect(result.total).toBe(0); + expect(result.totalPages).toBe(1); + expect(result.page).toBe(1); + }); + + it("keeps filtering and pagination combined and efficient for larger datasets", () => { + for (let i = 0; i < 120; i += 1) { + createTask({ + poster: `GPOSTER${i}`, + title: `Bounty ${i}`, + description: "Bulk seeded bounty for pagination testing.", + reward: 1_000_000 + i * 1000, + deadline: futureDeadline(3_600), + maxSubmissions: 1, + difficulty: i % 2 === 0 ? "beginner" : "advanced", + technologies: ["TypeScript"], + organization: "Bulk Org", + }); + } + + const start = Date.now(); + const result = listTasks({ + difficulty: "beginner", + technology: "typescript", + sort: "reward_desc", + page: 2, + pageSize: 10, + }); + const elapsedMs = Date.now() - start; + + expect(result.total).toBe(60); + expect(result.tasks).toHaveLength(10); + expect(elapsedMs).toBeLessThan(200); + }); +}); diff --git a/frontend/src/lib/task-workflow.ts b/frontend/src/lib/task-workflow.ts index 4d95115..e1a3337 100644 --- a/frontend/src/lib/task-workflow.ts +++ b/frontend/src/lib/task-workflow.ts @@ -2,8 +2,11 @@ import type { AddCommentInput, CommentRecord, CreateTaskInput, + ListTasksQuery, + ListTasksResult, SubmissionRecord, SubmitTaskInput, + TaskDifficulty, TaskRecord, TaskStatus, } from "@/types/task-workflow"; @@ -16,6 +19,11 @@ import { export const MIN_TASK_REWARD = 1_000_000; export const MAX_TASK_DEADLINE_OFFSET_SECONDS = 365 * 24 * 60 * 60; +export const DEFAULT_TASK_DIFFICULTY: TaskDifficulty = "intermediate"; +export const DEFAULT_PAGE_SIZE = 10; +export const MAX_PAGE_SIZE = 50; +const TASK_DIFFICULTIES: TaskDifficulty[] = ["beginner", "intermediate", "advanced"]; + type WorkflowSuccess = { ok: true } & T; type WorkflowFailure = { @@ -99,6 +107,14 @@ export function createTask( submissionCount: 0, status: "open", createdAt: now.toISOString(), + difficulty: + input.difficulty && TASK_DIFFICULTIES.includes(input.difficulty) + ? input.difficulty + : DEFAULT_TASK_DIFFICULTY, + technologies: (input.technologies ?? []) + .map((tech) => tech.trim()) + .filter((tech) => tech.length > 0), + organization: input.organization?.trim() ?? "", }; tasks.set(id, task); @@ -133,6 +149,82 @@ export function getTask(taskId: string): WorkflowResult<{ task: TaskRecord }> { return { ok: true, task: { ...task } }; } +/** + * Advanced bounty discovery: filters combine with AND semantics, results + * are sorted, then paginated. Pure/read-only — safe to call repeatedly as + * filter state changes. + */ +export function listTasks(query: ListTasksQuery = {}): ListTasksResult { + const search = query.search?.trim().toLowerCase(); + const technology = query.technology?.trim().toLowerCase(); + const organization = query.organization?.trim().toLowerCase(); + + const filtered = Array.from(tasks.values()).filter((task) => { + if (typeof query.minReward === "number" && task.reward < query.minReward) { + return false; + } + if (typeof query.maxReward === "number" && task.reward > query.maxReward) { + return false; + } + if (query.difficulty && task.difficulty !== query.difficulty) { + return false; + } + if ( + technology && + !task.technologies.some((tech) => tech.toLowerCase().includes(technology)) + ) { + return false; + } + if (organization && !task.organization.toLowerCase().includes(organization)) { + return false; + } + if (search) { + const haystack = `${task.title} ${task.description}`.toLowerCase(); + if (!haystack.includes(search)) { + return false; + } + } + return true; + }); + + const sort = query.sort ?? "newest"; + filtered.sort((a, b) => { + switch (sort) { + case "reward_desc": + return b.reward - a.reward; + case "deadline_asc": + return a.deadline - b.deadline; + case "newest": + default: + return b.createdAt.localeCompare(a.createdAt); + } + }); + + const total = filtered.length; + const pageSize = Math.min( + MAX_PAGE_SIZE, + Math.max( + 1, + Number.isFinite(query.pageSize) && query.pageSize + ? Math.floor(query.pageSize) + : DEFAULT_PAGE_SIZE, + ), + ); + const totalPages = Math.max(1, Math.ceil(total / pageSize)); + const page = Math.min( + totalPages, + Math.max( + 1, + Number.isFinite(query.page) && query.page ? Math.floor(query.page) : 1, + ), + ); + + const start = (page - 1) * pageSize; + const pageTasks = filtered.slice(start, start + pageSize).map((task) => ({ ...task })); + + return { tasks: pageTasks, total, page, pageSize, totalPages }; +} + export function submitTaskWork( input: SubmitTaskInput, files: ValidatedTaskSubmissionFile[], diff --git a/frontend/src/lib/tasks-list-api.test.ts b/frontend/src/lib/tasks-list-api.test.ts new file mode 100644 index 0000000..0c97b8c --- /dev/null +++ b/frontend/src/lib/tasks-list-api.test.ts @@ -0,0 +1,124 @@ +import { afterEach, describe, expect, it } from "vitest"; + +import { GET as listTasksRoute, POST as createTaskRoute } from "@/app/api/tasks/route"; +import { resetTaskWorkflowStore } from "@/lib/task-workflow"; + +function futureDeadline(offsetSeconds = 86_400) { + return Math.floor(Date.now() / 1000) + offsetSeconds; +} + +async function seedTask(overrides: Record = {}) { + return createTaskRoute( + new Request("http://localhost/api/tasks", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + poster: "GPOSTER1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ", + title: "Build a Soroban escrow contract", + description: "Implement escrow logic in Rust for Soroban.", + reward: 5_000_000, + deadline: futureDeadline(), + maxSubmissions: 3, + difficulty: "advanced", + technologies: ["Rust", "Soroban"], + organization: "Stellar Development Foundation", + ...overrides, + }), + }), + ); +} + +describe("GET /api/tasks", () => { + afterEach(() => { + resetTaskWorkflowStore(); + }); + + it("creates a task with difficulty/technologies/organization via POST", async () => { + const response = await seedTask(); + const body = await response.json(); + + expect(response.status).toBe(201); + expect(body.task).toMatchObject({ + difficulty: "advanced", + technologies: ["Rust", "Soroban"], + organization: "Stellar Development Foundation", + }); + }); + + it("lists created tasks with pagination metadata", async () => { + await seedTask({ title: "Task A" }); + await seedTask({ title: "Task B" }); + + const response = await listTasksRoute(new Request("http://localhost/api/tasks")); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body.ok).toBe(true); + expect(body.tasks).toHaveLength(2); + expect(body.pagination).toMatchObject({ page: 1, total: 2, totalPages: 1 }); + }); + + it("disables caching so listings are always fresh", async () => { + const response = await listTasksRoute(new Request("http://localhost/api/tasks")); + expect(response.headers.get("Cache-Control")).toBe("no-store"); + }); + + it("combines search, difficulty, and technology query params", async () => { + await seedTask({ title: "Escrow contract", difficulty: "advanced" }); + await seedTask({ + title: "Landing page", + description: "Marketing site", + difficulty: "beginner", + technologies: ["Figma"], + organization: "Acme DAO", + }); + + const response = await listTasksRoute( + new Request( + "http://localhost/api/tasks?search=escrow&difficulty=advanced&technology=rust", + ), + ); + const body = await response.json(); + + expect(body.tasks).toHaveLength(1); + expect(body.tasks[0].title).toBe("Escrow contract"); + }); + + it("ignores an invalid difficulty or sort value instead of erroring", async () => { + await seedTask(); + + const response = await listTasksRoute( + new Request("http://localhost/api/tasks?difficulty=nonsense&sort=bogus"), + ); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body.tasks).toHaveLength(1); + }); + + it("paginates via page/pageSize query params", async () => { + for (let i = 0; i < 5; i += 1) { + await seedTask({ title: `Task ${i}` }); + } + + const response = await listTasksRoute( + new Request("http://localhost/api/tasks?pageSize=2&page=3"), + ); + const body = await response.json(); + + expect(body.tasks).toHaveLength(1); + expect(body.pagination).toMatchObject({ page: 3, pageSize: 2, total: 5, totalPages: 3 }); + }); + + it("sorts by highest reward via the sort query param", async () => { + await seedTask({ title: "Low", reward: 1_000_000 }); + await seedTask({ title: "High", reward: 9_000_000 }); + + const response = await listTasksRoute( + new Request("http://localhost/api/tasks?sort=reward_desc"), + ); + const body = await response.json(); + + expect(body.tasks.map((t: { title: string }) => t.title)).toEqual(["High", "Low"]); + }); +}); diff --git a/frontend/src/types/task-workflow.ts b/frontend/src/types/task-workflow.ts index e7f76c8..3b07e17 100644 --- a/frontend/src/types/task-workflow.ts +++ b/frontend/src/types/task-workflow.ts @@ -2,6 +2,8 @@ export type TaskStatus = "open" | "in_progress" | "completed" | "cancelled" | "d export type SubmissionStatus = "pending" | "approved" | "rejected"; +export type TaskDifficulty = "beginner" | "intermediate" | "advanced"; + export interface TaskRecord { id: string; poster: string; @@ -13,6 +15,11 @@ export interface TaskRecord { submissionCount: number; status: TaskStatus; createdAt: string; + difficulty: TaskDifficulty; + /** Technology/skill tags, e.g. ["Rust", "Soroban"]. */ + technologies: string[]; + /** Name of the organization or team posting the bounty, if any. */ + organization: string; } export interface SubmissionRecord { @@ -39,6 +46,9 @@ export interface CreateTaskInput { reward: number; deadline: number; maxSubmissions: number; + difficulty?: TaskDifficulty; + technologies?: string[]; + organization?: string; } export interface SubmitTaskInput { @@ -63,3 +73,30 @@ export interface AddCommentInput { author: string; message: string; } + +export type TaskSortOrder = "newest" | "reward_desc" | "deadline_asc"; + +export interface ListTasksQuery { + /** Case-insensitive substring match against title and description. */ + search?: string; + minReward?: number; + maxReward?: number; + difficulty?: TaskDifficulty; + /** Matches tasks whose technologies list includes this value (case-insensitive). */ + technology?: string; + /** Case-insensitive substring match against organization. */ + organization?: string; + sort?: TaskSortOrder; + /** 1-based page number. Defaults to 1. */ + page?: number; + /** Items per page. Defaults to 10, clamped to [1, 50]. */ + pageSize?: number; +} + +export interface ListTasksResult { + tasks: TaskRecord[]; + total: number; + page: number; + pageSize: number; + totalPages: number; +}