From f9b40363f29accdb642ea555fe64b0256c5141b2 Mon Sep 17 00:00:00 2001 From: Osuochasam Date: Thu, 30 Jul 2026 05:00:19 +0100 Subject: [PATCH 1/4] Fix lint errors blocking Frontend CI - useNotificationPreferences: load persisted prefs via a useState lazy initializer instead of setState in an effect body, and defer the loading-flag flip to a microtask to satisfy react-hooks/set-state-in-effect. - useNotificationPreferences.test.ts: drop the unused `any`-cast import and other unused variables flagged by no-explicit-any / no-unused-vars. - rate-limit.test.ts: replace the built-in `Function` type with an explicit RouteHandler signature to satisfy no-unsafe-function-type. --- .../hooks/useNotificationPreferences.test.ts | 20 +++---------------- .../src/hooks/useNotificationPreferences.ts | 16 ++++++++------- frontend/src/lib/rate-limit.test.ts | 7 ++++++- 3 files changed, 18 insertions(+), 25 deletions(-) diff --git a/frontend/src/hooks/useNotificationPreferences.test.ts b/frontend/src/hooks/useNotificationPreferences.test.ts index e1006c1..4c1585d 100644 --- a/frontend/src/hooks/useNotificationPreferences.test.ts +++ b/frontend/src/hooks/useNotificationPreferences.test.ts @@ -15,7 +15,6 @@ import { NOTIFICATION_CATEGORIES, NOTIFICATION_CATEGORY_LABELS, NOTIFICATION_PREFS_KEY, - type NotificationCategory, type NotificationPreferences, } from "./useNotificationPreferences"; @@ -105,17 +104,10 @@ describe("NOTIFICATION_CATEGORY_LABELS", () => { // Storage round-trip (import helpers under a controlled window stub) // --------------------------------------------------------------------------- -async function getHelpers() { - vi.resetModules(); - const mod = await import("./useNotificationPreferences"); - return mod; -} - describe("localStorage persistence helpers", () => { - it("loadFromStorage returns null when no key is stored", async () => { - const { loadFromStorage } = await import("./useNotificationPreferences") as any; - // loadFromStorage is not exported — tested indirectly via saveToStorage below - // We verify round-trip via save + item presence + it("loadFromStorage returns null when no key is stored", () => { + // loadFromStorage is internal; its contract is verified indirectly via + // the storage round-trip below. expect(localStorageMock.getItem(NOTIFICATION_PREFS_KEY)).toBeNull(); }); @@ -208,12 +200,6 @@ describe("preference mutation helpers", () => { }); it("reset restores defaults", () => { - const modified: NotificationPreferences = { - ...DEFAULT_NOTIFICATION_PREFERENCES, - payments: false, - disputes: false, - }; - // After reset we should get defaults back const afterReset: NotificationPreferences = { ...DEFAULT_NOTIFICATION_PREFERENCES }; diff --git a/frontend/src/hooks/useNotificationPreferences.ts b/frontend/src/hooks/useNotificationPreferences.ts index 9672563..184238b 100644 --- a/frontend/src/hooks/useNotificationPreferences.ts +++ b/frontend/src/hooks/useNotificationPreferences.ts @@ -177,18 +177,20 @@ function removeFromStorage(): void { * ``` */ export function useNotificationPreferences(): UseNotificationPreferencesReturn { + // Lazy initializer reads localStorage on the client's first render, so no + // setState call is needed inside an effect to sync the loaded value in. const [preferences, setPreferences] = useState( - DEFAULT_NOTIFICATION_PREFERENCES, + () => loadFromStorage() ?? DEFAULT_NOTIFICATION_PREFERENCES, ); const [isLoading, setIsLoading] = useState(true); - // Load persisted preferences on mount (client-side only). + // Preferences are already loaded via the lazy initializer above; this + // effect only flips the loading flag once the client has mounted so the + // server-rendered skeleton (loading=true) matches the initial client render. + // The flip is deferred to a microtask (rather than called synchronously in + // the effect body) to avoid cascading renders within the same commit. useEffect(() => { - const stored = loadFromStorage(); - if (stored) { - setPreferences(stored); - } - setIsLoading(false); + queueMicrotask(() => setIsLoading(false)); }, []); const toggle = useCallback((category: NotificationCategory) => { diff --git a/frontend/src/lib/rate-limit.test.ts b/frontend/src/lib/rate-limit.test.ts index eb52eb0..6513d92 100644 --- a/frontend/src/lib/rate-limit.test.ts +++ b/frontend/src/lib/rate-limit.test.ts @@ -200,8 +200,13 @@ describe("endpoint rate-limit integration", () => { process.env.API_RATE_LIMIT_WINDOW_MS = "60000"; }); + type RouteHandler = ( + request: Request, + context: { params: Promise<{ taskId: string }> }, + ) => Promise; + async function checkEndpointEnforces( - importFn: () => Promise<{ GET?: Function; POST?: Function }>, + importFn: () => Promise<{ GET?: RouteHandler; POST?: RouteHandler }>, method: "GET" | "POST", buildRequest: () => Request, ) { From 60d8bbc133d30ebcf47e1b650c2a40e95f48d983 Mon Sep 17 00:00:00 2001 From: Osuochasam Date: Thu, 30 Jul 2026 05:00:43 +0100 Subject: [PATCH 2/4] Add advanced filtering for bounty discovery Extends TaskRecord with difficulty, technologies, and organization fields, and adds a listTasks() query layer supporting combinable filters (reward range, difficulty, technology, organization), keyword search across title and description, and sorting (newest, highest reward, soonest deadline). Pagination metadata (page/pageSize/total/totalPages) is computed server-side so it stays correct as filters narrow the result set. Exposes this via GET /api/tasks (query-param driven), a useBountyDiscovery hook that debounces keyword search and cancels stale requests via AbortController, and a new /bounties page with combinable filter controls and prev/next pagination. --- .../src/app/(dashboard)/bounties/page.tsx | 27 ++ frontend/src/app/api/tasks/route.ts | 65 ++++- frontend/src/components/BountyFilters.tsx | 178 ++++++++++++ frontend/src/components/BountyList.tsx | 116 ++++++++ frontend/src/components/Navbar.tsx | 1 + frontend/src/hooks/useBountyDiscovery.ts | 165 ++++++++++++ frontend/src/lib/task-listing.test.ts | 254 ++++++++++++++++++ frontend/src/lib/task-workflow.ts | 92 +++++++ frontend/src/lib/tasks-list-api.test.ts | 124 +++++++++ frontend/src/types/task-workflow.ts | 37 +++ 10 files changed, 1058 insertions(+), 1 deletion(-) create mode 100644 frontend/src/app/(dashboard)/bounties/page.tsx create mode 100644 frontend/src/components/BountyFilters.tsx create mode 100644 frontend/src/components/BountyList.tsx create mode 100644 frontend/src/hooks/useBountyDiscovery.ts create mode 100644 frontend/src/lib/task-listing.test.ts create mode 100644 frontend/src/lib/tasks-list-api.test.ts 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 5ad023b..66cf119 100644 --- a/frontend/src/components/Navbar.tsx +++ b/frontend/src/components/Navbar.tsx @@ -7,6 +7,7 @@ import Image from "next/image"; 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/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 4d29708..fe29d18 100644 --- a/frontend/src/lib/task-workflow.ts +++ b/frontend/src/lib/task-workflow.ts @@ -1,7 +1,10 @@ import type { CreateTaskInput, + ListTasksQuery, + ListTasksResult, SubmissionRecord, SubmitTaskInput, + TaskDifficulty, TaskRecord, TaskStatus, } from "@/types/task-workflow"; @@ -10,6 +13,11 @@ import type { ValidatedTaskSubmissionFile } from "@/lib/task-submission-files"; 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 = { @@ -91,6 +99,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); @@ -114,6 +130,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 39edb1b..dc5659d 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 { @@ -47,3 +57,30 @@ export interface SubmitTaskInput { description: string; workUrl?: 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; +} From 622d83f9d2adb18ae834d8d2594246f66cd8329b Mon Sep 17 00:00:00 2001 From: Osuochasam Date: Thu, 30 Jul 2026 05:26:24 +0100 Subject: [PATCH 3/4] Fix pre-existing test bugs breaking the frontend CI test step These 3 files had broken tests unrelated to bounty discovery, but they run as part of the same `pnpm test` step and were blocking the PR: - form-validation.test.ts: several Stellar address fixtures were the wrong length (59/65 chars instead of the required 56), so they hit the length-check branch instead of the branch they meant to exercise. Rebuilt them as valid 56-char base32 addresses. Also fixed the "rejects URL shorter than minimum" case, which used an 11-char URL against a 5-char minimum (i.e. never actually shorter than the minimum). - auth.integration.test.ts: accessProtectedRoute deletes the session as a side effect of detecting expiry, so calling it twice and asserting on two separate invocations made the second call see "missing-session" instead of "session-expired". Now calls it once and asserts on the single result. - security-headers.test.ts: imported "../../../security-headers.mjs" (three levels up, past the frontend/ root) instead of "../../security-headers.mjs", so the module never resolved. --- frontend/src/lib/auth.integration.test.ts | 10 ++++++---- frontend/src/lib/form-validation.test.ts | 19 +++++++++---------- frontend/src/lib/security-headers.test.ts | 2 +- 3 files changed, 16 insertions(+), 15 deletions(-) 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 From 90aa9b972daea75ce6c7973d492a1c2e1274b820 Mon Sep 17 00:00:00 2001 From: Osuochasam Date: Thu, 30 Jul 2026 05:34:57 +0100 Subject: [PATCH 4/4] Restore TaskSortOrder/ListTasksQuery/ListTasksResult dropped by merge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The merge of origin/feat/bounty-discovery-filters (which pulled in main, including the now-merged live-notifications work) auto-resolved types/task-workflow.ts cleanly but silently dropped the TaskSortOrder, ListTasksQuery, and ListTasksResult types — both branches inserted new content right after SubmitTaskInput, and git's merge picked one insertion without flagging a conflict. This broke the build (GET /api/tasks referenced TaskSortOrder, which no longer existed). Restored the missing types; build, lint, and full test suite (299 tests) pass again. --- frontend/src/types/task-workflow.ts | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/frontend/src/types/task-workflow.ts b/frontend/src/types/task-workflow.ts index 0ec590f..3b07e17 100644 --- a/frontend/src/types/task-workflow.ts +++ b/frontend/src/types/task-workflow.ts @@ -73,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; +}