From 2f1b3858461d1fa33cc38d529493d0037fcbab23 Mon Sep 17 00:00:00 2001 From: damispicyGithub Date: Thu, 24 Sep 2026 23:30:11 +0100 Subject: [PATCH] feat(projects): add advanced project filtering & sorting (#181) - add project discovery service with query, status, skill, and budget-range filters plus whitelisted sort (newest/budget/deadline) - add GET /api/projects/discover with pagination metadata and structured validation errors - add responsive /projects discovery page with URL-persisted filters, debounced requests, loading skeletons, and empty states - add migration for deadline/skills/category columns and indexes - link Browse Projects from the navbar - add unit tests for param parsing, row mapping, and the endpoint --- __tests__/api/project-discovery.test.ts | 256 ++++++++ app/api/projects/discover/route.ts | 41 ++ app/projects/page.tsx | 17 + components/navbar.tsx | 6 + components/projects/project-discovery.tsx | 647 ++++++++++++++++++++ lib/db/migrations/010_project_discovery.sql | 34 + lib/projectDiscovery.ts | 431 +++++++++++++ 7 files changed, 1432 insertions(+) create mode 100644 __tests__/api/project-discovery.test.ts create mode 100644 app/api/projects/discover/route.ts create mode 100644 app/projects/page.tsx create mode 100644 components/projects/project-discovery.tsx create mode 100644 lib/db/migrations/010_project_discovery.sql create mode 100644 lib/projectDiscovery.ts diff --git a/__tests__/api/project-discovery.test.ts b/__tests__/api/project-discovery.test.ts new file mode 100644 index 0000000..bb1d8e4 --- /dev/null +++ b/__tests__/api/project-discovery.test.ts @@ -0,0 +1,256 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +import { GET as discoverProjects } from '@/app/api/projects/discover/route' + +vi.mock('@/lib/db', () => ({ + sql: vi.fn(), +})) + +import { sql } from '@/lib/db' +import { + mapProjectRowToListing, + parseDiscoveryParams, + ProjectDiscoveryError, + PROJECT_SORTABLE_FIELDS, + PROJECT_MAX_LIMIT, + PROJECT_DEFAULT_LIMIT, + PROJECT_STATUSES, +} from '@/lib/projectDiscovery' +import { NextRequest } from 'next/server' + +type SqlMock = ReturnType + +function buildProjectRow( + overrides: Record = {}, +): Array> { + return [ + { + id: 'proj-1', + client_id: 'client-1', + title: 'Build a dashboard', + description: 'React dashboard with charts', + budget_usdc: '1000.5', + status: 'open', + skills: ['React', 'TypeScript'], + category: 'Frontend', + deadline: new Date('2026-12-01T00:00:00Z'), + created_at: new Date('2026-01-01T00:00:00Z'), + total_count: '3', + ...overrides, + }, + ] +} + +function queueSql(responses: unknown[]) { + const mock = sql as unknown as SqlMock + for (const response of responses) { + mock.mockResolvedValueOnce(response) + } +} + +function queueSqlReject(error: unknown) { + const mock = sql as unknown as SqlMock + mock.mockRejectedValueOnce(error) +} + +function makeRequest(url: string): NextRequest { + return new NextRequest(new Request(url)) +} + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('parseDiscoveryParams', () => { + it('applies defaults when no params are provided', () => { + const params = parseDiscoveryParams(new URLSearchParams()) + expect(params).toEqual({ + query: '', + statuses: [], + skills: [], + minBudget: null, + maxBudget: null, + sort: 'created_at', + order: 'desc', + limit: PROJECT_DEFAULT_LIMIT, + page: 1, + }) + }) + + it('dedupes skills and statuses across repeating and comma-separated values', () => { + const params = parseDiscoveryParams( + new URLSearchParams( + 'skills=react&skills=react,nodejs&status=open,in_progress&status=open', + ), + ) + expect(params.skills).toEqual(['react', 'nodejs']) + expect(params.statuses).toEqual(['open', 'in_progress']) + }) + + it('rejects unknown statuses', () => { + expect(() => parseDiscoveryParams(new URLSearchParams('status=archived'))) + .toThrowError(ProjectDiscoveryError) + }) + + it('rejects unknown sort fields', () => { + expect(() => parseDiscoveryParams(new URLSearchParams('sort=password'))) + .toThrowError(ProjectDiscoveryError) + }) + + it('accepts every whitelisted sort field', () => { + for (const field of PROJECT_SORTABLE_FIELDS) { + const params = parseDiscoveryParams(new URLSearchParams(`sort=${field}`)) + expect(params.sort).toBe(field) + } + }) + + it('rejects bogus order values', () => { + expect(() => parseDiscoveryParams(new URLSearchParams('order=ascending'))) + .toThrowError(ProjectDiscoveryError) + }) + + it('parses a budget range', () => { + const params = parseDiscoveryParams(new URLSearchParams('minBudget=100&maxBudget=2500')) + expect(params.minBudget).toBe(100) + expect(params.maxBudget).toBe(2500) + }) + + it('rejects a negative budget', () => { + expect(() => parseDiscoveryParams(new URLSearchParams('minBudget=-5'))) + .toThrowError(ProjectDiscoveryError) + }) + + it('rejects minBudget greater than maxBudget', () => { + expect(() => + parseDiscoveryParams(new URLSearchParams('minBudget=500&maxBudget=100')), + ).toThrowError(ProjectDiscoveryError) + }) + + it('clamps limit above the maximum', () => { + const params = parseDiscoveryParams( + new URLSearchParams(`limit=${PROJECT_MAX_LIMIT * 10}`), + ) + expect(params.limit).toBe(PROJECT_MAX_LIMIT) + }) + + it('rejects zero or negative page', () => { + expect(() => parseDiscoveryParams(new URLSearchParams('page=0'))) + .toThrowError(ProjectDiscoveryError) + expect(() => parseDiscoveryParams(new URLSearchParams('page=-1'))) + .toThrowError(ProjectDiscoveryError) + }) + + it('trims the search query', () => { + const params = parseDiscoveryParams(new URLSearchParams('q=%20%20hello%20%20')) + expect(params.query).toBe('hello') + }) +}) + +describe('mapProjectRowToListing', () => { + it('maps snake_case DB fields and normalises optional values', () => { + const listing = mapProjectRowToListing({ + id: 'proj-1', + client_id: 'client-1', + title: 'Landing page', + description: null, + budget_usdc: '250.25', + status: 'open', + skills: null, + category: null, + deadline: null, + created_at: new Date('2026-05-01T00:00:00Z'), + }) + + expect(listing).toMatchObject({ + id: 'proj-1', + clientId: 'client-1', + description: null, + budgetUsdc: 250.25, + skills: [], + category: null, + deadline: null, + createdAt: '2026-05-01T00:00:00.000Z', + }) + }) +}) + +describe('GET /api/projects/discover', () => { + it('returns the discovery payload with pagination metadata', async () => { + // Path: WHERE fragment → ORDER BY fragment → main list → skills query. + queueSql([ + [], // WHERE fragment + [], // ORDER BY fragment + buildProjectRow(), // main list (1 row → no fallback COUNT) + [{ skill: 'React' }, { skill: 'Node.js' }], // available skills + ]) + + const request = makeRequest( + 'http://localhost/api/projects/discover?q=dashboard&status=open&sort=budget&order=asc&limit=2', + ) + const response = await discoverProjects(request) + + expect(response.status).toBe(200) + const body = await response.json() + expect(body.projects).toHaveLength(1) + expect(body.projects[0].title).toBe('Build a dashboard') + expect(body.skills).toEqual(['React', 'Node.js']) + expect(body.statuses).toEqual([...PROJECT_STATUSES]) + expect(body.pagination).toEqual({ + page: 1, + pageSize: 1, + totalItems: 3, + totalPages: 2, + }) + }) + + it('returns an accurate total when the requested page is past the end', async () => { + // WHERE → ORDER BY → main (empty) → WHERE → COUNT → skills. + queueSql([ + [], // WHERE fragment (list) + [], // ORDER BY fragment + [], // main list (empty) + [], // WHERE fragment (count) + [{ count: '3' }], // COUNT(*) + [], // skills + ]) + + const request = makeRequest('http://localhost/api/projects/discover?page=99&limit=10') + const response = await discoverProjects(request) + const body = await response.json() + + expect(response.status).toBe(200) + expect(body.projects).toEqual([]) + expect(body.pagination).toEqual({ + page: 1, + pageSize: 0, + totalItems: 3, + totalPages: 1, + }) + }) + + it('returns 400 with a structured error on an invalid sort field', async () => { + const request = makeRequest( + 'http://localhost/api/projects/discover?sort=payout_total', + ) + const response = await discoverProjects(request) + + expect(response.status).toBe(400) + const body = await response.json() + expect(body.code).toBe('INVALID_SORT_FIELD') + }) + + it('returns 503 when the DB query fails', async () => { + queueSql([[]]) // WHERE fragment resolves + queueSqlReject(new Error('connection reset')) // ORDER BY fragment rejects + + const request = makeRequest('http://localhost/api/projects/discover') + const response = await discoverProjects(request) + + expect(response.status).toBe(503) + const body = await response.json() + expect(body).toEqual({ + error: 'Unable to load projects', + code: 'PROJECT_LIST_FAILED', + }) + }) +}) diff --git a/app/api/projects/discover/route.ts b/app/api/projects/discover/route.ts new file mode 100644 index 0000000..10a24a3 --- /dev/null +++ b/app/api/projects/discover/route.ts @@ -0,0 +1,41 @@ +// app/api/projects/discover/route.ts +// +// GET /api/projects/discover — public project discovery endpoint. +// +// Supported query parameters: +// q / query free-text search over title & description +// status repeatable / comma-separated project status(es) +// skills repeatable / comma-separated required skill(s) +// minBudget inclusive lower budget bound +// maxBudget inclusive upper budget bound +// sort created_at | budget | deadline (default created_at) +// order asc | desc (default desc) +// page 1-based page number (default 1) +// limit items per page, max 50 (default 9) + +import { NextRequest, NextResponse } from 'next/server' +import { + buildListResponse, + parseDiscoveryParams, + ProjectDiscoveryError, +} from '@/lib/projectDiscovery' + +export async function GET(req: NextRequest) { + try { + const params = parseDiscoveryParams(req.nextUrl.searchParams) + const payload = await buildListResponse(params) + return NextResponse.json(payload) + } catch (err) { + if (err instanceof ProjectDiscoveryError) { + return NextResponse.json( + { error: err.message, code: err.code }, + { status: 400 }, + ) + } + console.error('[GET /api/projects/discover]', err) + return NextResponse.json( + { error: 'Unable to load projects', code: 'PROJECT_LIST_FAILED' }, + { status: 503 }, + ) + } +} diff --git a/app/projects/page.tsx b/app/projects/page.tsx new file mode 100644 index 0000000..a94114e --- /dev/null +++ b/app/projects/page.tsx @@ -0,0 +1,17 @@ +import { Suspense } from 'react' +import type { Metadata } from 'next' +import { ProjectDiscovery } from '@/components/projects/project-discovery' + +export const metadata: Metadata = { + title: 'Browse Projects | TaskChain', + description: + 'Discover open TaskChain projects. Filter by budget, status, and required skills, then sort by newest, budget, or deadline.', +} + +export default function ProjectsPage() { + return ( + + + + ) +} diff --git a/components/navbar.tsx b/components/navbar.tsx index a9ddc9c..ab65ead 100644 --- a/components/navbar.tsx +++ b/components/navbar.tsx @@ -84,6 +84,9 @@ export function Navbar() { Browse Freelancers + + Browse Projects + Features @@ -219,6 +222,9 @@ export function Navbar() { Browse Freelancers + + Browse Projects + Features diff --git a/components/projects/project-discovery.tsx b/components/projects/project-discovery.tsx new file mode 100644 index 0000000..8b33b1c --- /dev/null +++ b/components/projects/project-discovery.tsx @@ -0,0 +1,647 @@ +'use client' + +import Link from 'next/link' +import { useCallback, useEffect, useMemo, useState } from 'react' +import { usePathname, useRouter, useSearchParams } from 'next/navigation' +import { + CalendarClock, + CircleDollarSign, + Filter, + FolderKanban, + Loader2, + RotateCcw, + Search, + SlidersHorizontal, +} from 'lucide-react' +import { Navbar } from '@/components/navbar' +import { Footer } from '@/components/footer' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Checkbox } from '@/components/ui/checkbox' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { cn } from '@/lib/utils' + +interface ProjectListing { + id: string + clientId: string + title: string + description: string | null + budgetUsdc: number + status: string + skills: string[] + category: string | null + deadline: string | null + createdAt: string +} + +interface DiscoveryResponse { + projects: ProjectListing[] + skills: string[] + statuses: string[] + pagination: { + page: number + pageSize: number + totalItems: number + totalPages: number + } +} + +const DEFAULT_RESPONSE: DiscoveryResponse = { + projects: [], + skills: [], + statuses: ['open', 'in_progress', 'completed', 'cancelled'], + pagination: { page: 1, pageSize: 0, totalItems: 0, totalPages: 1 }, +} + +const STATUS_OPTIONS: { value: string; label: string }[] = [ + { value: 'open', label: 'Open' }, + { value: 'in_progress', label: 'In Progress' }, + { value: 'completed', label: 'Completed' }, + { value: 'cancelled', label: 'Cancelled' }, +] + +const SORT_OPTIONS = [ + { value: 'newest', label: 'Newest first' }, + { value: 'budget_desc', label: 'Budget: high to low' }, + { value: 'budget_asc', label: 'Budget: low to high' }, + { value: 'deadline_asc', label: 'Deadline: soonest' }, + { value: 'deadline_desc', label: 'Deadline: latest' }, +] + +const STATUS_STYLES: Record = { + open: 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400', + in_progress: 'bg-secondary/20 text-secondary', + completed: 'bg-accent/20 text-accent', + cancelled: 'bg-muted text-muted-foreground', +} + +function sortToParams(value: string): { sort: string; order: string } { + switch (value) { + case 'budget_asc': + return { sort: 'budget', order: 'asc' } + case 'budget_desc': + return { sort: 'budget', order: 'desc' } + case 'deadline_asc': + return { sort: 'deadline', order: 'asc' } + case 'deadline_desc': + return { sort: 'deadline', order: 'desc' } + default: + return { sort: 'created_at', order: 'desc' } + } +} + +function paramsToSort(sort: string | null, order: string | null): string { + if (sort === 'budget') return order === 'asc' ? 'budget_asc' : 'budget_desc' + if (sort === 'deadline') return order === 'asc' ? 'deadline_asc' : 'deadline_desc' + return 'newest' +} + +function parseUrlList(values: string[]): string[] { + const seen = new Set() + const out: string[] = [] + for (const value of values) { + for (const part of value.split(',')) { + const trimmed = part.trim() + if (!trimmed || seen.has(trimmed.toLowerCase())) continue + seen.add(trimmed.toLowerCase()) + out.push(trimmed) + } + } + return out +} + +function toggleValue(list: string[], value: string): string[] { + return list.includes(value) + ? list.filter((item) => item !== value) + : [...list, value] +} + +function formatBudget(value: number): string { + return `$${value.toLocaleString(undefined, { maximumFractionDigits: 2 })}` +} + +function formatDeadline(value: string | null): string { + if (!value) return 'No deadline' + const date = new Date(value) + if (Number.isNaN(date.getTime())) return 'No deadline' + return date.toLocaleDateString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + }) +} + +function LoadingCards() { + return ( +
+ {Array.from({ length: 6 }, (_, index) => ( + + +
+
+ + +
+
+
+
+
+ + + ))} +
+ ) +} + +interface FilterPanelProps { + search: string + setSearch: (value: string) => void + statuses: string[] + setStatuses: (value: string[]) => void + skills: string[] + setSkills: (value: string[]) => void + availableSkills: string[] + minBudget: string + setMinBudget: (value: string) => void + maxBudget: string + setMaxBudget: (value: string) => void + hasActiveFilters: boolean + clearFilters: () => void +} + +function FilterPanel({ + search, + setSearch, + statuses, + setStatuses, + skills, + setSkills, + availableSkills, + minBudget, + setMinBudget, + maxBudget, + setMaxBudget, + hasActiveFilters, + clearFilters, +}: FilterPanelProps) { + return ( +
+
+
+ Filters +
+ {hasActiveFilters && ( + + )} +
+ +
+ +
+ + setSearch(event.target.value)} + placeholder="Title, description..." + className="pl-9" + /> +
+
+ +
+

Status

+
+ {STATUS_OPTIONS.map((option) => ( + + ))} +
+
+ +
+

Budget range (USDC)

+
+ setMinBudget(event.target.value)} + /> + setMaxBudget(event.target.value)} + /> +
+
+ +
+

Required skills

+ {availableSkills.length === 0 ? ( +

No skills available yet.

+ ) : ( +
+ {availableSkills.map((skill) => ( + + ))} +
+ )} +
+
+ ) +} + +export function ProjectDiscovery() { + const router = useRouter() + const pathname = usePathname() + const searchParams = useSearchParams() + + const [search, setSearch] = useState(() => searchParams.get('q') ?? '') + const [statuses, setStatuses] = useState(() => + parseUrlList(searchParams.getAll('status')), + ) + const [skills, setSkills] = useState(() => + parseUrlList(searchParams.getAll('skills')), + ) + const [minBudget, setMinBudget] = useState( + () => searchParams.get('minBudget') ?? '', + ) + const [maxBudget, setMaxBudget] = useState( + () => searchParams.get('maxBudget') ?? '', + ) + const [sort, setSort] = useState(() => + paramsToSort(searchParams.get('sort'), searchParams.get('order')), + ) + const [page, setPage] = useState(() => { + const parsed = Number.parseInt(searchParams.get('page') ?? '1', 10) + return Number.isInteger(parsed) && parsed > 0 ? parsed : 1 + }) + + const [data, setData] = useState(DEFAULT_RESPONSE) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [filtersOpen, setFiltersOpen] = useState(false) + + const queryString = useMemo(() => { + const params = new URLSearchParams() + if (search.trim()) params.set('q', search.trim()) + for (const status of statuses) params.append('status', status) + for (const skill of skills) params.append('skills', skill) + if (minBudget.trim()) params.set('minBudget', minBudget.trim()) + if (maxBudget.trim()) params.set('maxBudget', maxBudget.trim()) + const { sort: sortField, order } = sortToParams(sort) + params.set('sort', sortField) + params.set('order', order) + params.set('page', String(page)) + return params.toString() + }, [search, statuses, skills, minBudget, maxBudget, sort, page]) + + const loadProjects = useCallback(async () => { + setLoading(true) + try { + const response = await fetch(`/api/projects/discover?${queryString}`, { + cache: 'no-store', + }) + if (!response.ok) throw new Error('Project search failed') + const payload = (await response.json()) as DiscoveryResponse + setData(payload) + setError(null) + } catch { + setError('Unable to load projects. Please try again.') + setData(DEFAULT_RESPONSE) + } finally { + setLoading(false) + } + }, [queryString]) + + // Debounced fetch: avoids a request per keystroke. + useEffect(() => { + const timeout = window.setTimeout(() => { + void loadProjects() + }, 300) + return () => window.clearTimeout(timeout) + }, [loadProjects]) + + // Persist the active filters in the URL so refresh / shared links restore them. + useEffect(() => { + const target = queryString ? `${pathname}?${queryString}` : pathname + const timeout = window.setTimeout(() => { + router.replace(target, { scroll: false }) + }, 300) + return () => window.clearTimeout(timeout) + }, [queryString, pathname, router]) + + const clearFilters = useCallback(() => { + setSearch('') + setStatuses([]) + setSkills([]) + setMinBudget('') + setMaxBudget('') + setSort('newest') + setPage(1) + }, []) + + const hasActiveFilters = + search.trim().length > 0 || + statuses.length > 0 || + skills.length > 0 || + minBudget.trim().length > 0 || + maxBudget.trim().length > 0 || + sort !== 'newest' + + const filterPanelProps: FilterPanelProps = { + search, + setSearch: (value) => { + setSearch(value) + setPage(1) + }, + statuses, + setStatuses: (value) => { + setStatuses(value) + setPage(1) + }, + skills, + setSkills: (value) => { + setSkills(value) + setPage(1) + }, + availableSkills: data.skills, + minBudget, + setMinBudget: (value) => { + setMinBudget(value) + setPage(1) + }, + maxBudget, + setMaxBudget: (value) => { + setMaxBudget(value) + setPage(1) + }, + hasActiveFilters, + clearFilters, + } + + return ( +
+ + +
+
+
+ + Project marketplace + +

+ Discover your next TaskChain project +

+

+ Filter by budget, status, and required skills, then sort by newest, + budget, or deadline to find the right fit. +

+
+
+
+ +
+ + +
+
+
+ + + + + + + Filter projects + + +
+ +
+
+
+ +
+

Showing

+

+ {data.pagination.totalItems} project + {data.pagination.totalItems === 1 ? '' : 's'} found +

+
+
+ +
+ +
+
+ + {error && ( +
+ {error} +
+ )} + + {loading ? ( + + ) : data.projects.length === 0 ? ( +
+ +

+ No projects match your filters +

+

+ Try widening the budget range, removing a skill, or clearing the + search. +

+ +
+ ) : ( +
+ {data.projects.map((project) => ( + + +
+ + {project.title} + + + {project.status.replace('_', ' ')} + +
+ {project.category && ( +

+ {project.category} +

+ )} +
+ +

+ {project.description || 'No description provided.'} +

+ + {project.skills.length > 0 && ( +
+ {project.skills.slice(0, 4).map((skill) => ( + + {skill} + + ))} + {project.skills.length > 4 && ( + + +{project.skills.length - 4} + + )} +
+ )} + +
+
+ +
+

Budget

+

+ {formatBudget(project.budgetUsdc)} +

+
+
+
+ +
+

Deadline

+

+ {formatDeadline(project.deadline)} +

+
+
+
+ + +
+
+ ))} +
+ )} + + {!loading && data.projects.length > 0 && ( +
+ + + Page {data.pagination.page} of {data.pagination.totalPages} + + +
+ )} +
+
+ +
+
+ ) +} diff --git a/lib/db/migrations/010_project_discovery.sql b/lib/db/migrations/010_project_discovery.sql new file mode 100644 index 0000000..0ef01c1 --- /dev/null +++ b/lib/db/migrations/010_project_discovery.sql @@ -0,0 +1,34 @@ +-- 010_project_discovery.sql +-- +-- Advanced project filtering & sorting (issue #181). +-- +-- Ensures the `projects` table carries every column the discovery service +-- (lib/projectDiscovery.ts) filters or sorts on: +-- * deadline — enables "deadline" sorting +-- * skills — enables multi-select "required skills" filtering +-- * category — lets the discovery UI surface a category badge +-- +-- `skills`/`category` are also introduced by scripts/012-project-recommendation- +-- columns.sql; the IF NOT EXISTS guards below make this migration safe to run +-- regardless of which schema path a deployment took. + +ALTER TABLE projects + ADD COLUMN IF NOT EXISTS deadline TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS skills TEXT[] DEFAULT '{}', + ADD COLUMN IF NOT EXISTS category VARCHAR(100); + +-- Range scans for budget filtering/sorting. +CREATE INDEX IF NOT EXISTS idx_projects_budget_usdc + ON projects (budget_usdc); + +-- Deadline sorting / "ending soon" queries. +CREATE INDEX IF NOT EXISTS idx_projects_deadline + ON projects (deadline); + +-- Skill overlap filtering (required skills). +CREATE INDEX IF NOT EXISTS idx_projects_skills_gin + ON projects USING GIN (skills); + +-- Combined status + recency ordering used by the default "newest first" view. +CREATE INDEX IF NOT EXISTS idx_projects_status_created + ON projects (status, created_at DESC); diff --git a/lib/projectDiscovery.ts b/lib/projectDiscovery.ts new file mode 100644 index 0000000..c435e39 --- /dev/null +++ b/lib/projectDiscovery.ts @@ -0,0 +1,431 @@ +/** + * Project Discovery API helper. + * + * Encapsulates the SQL query logic, pagination math, sort/filter validation, + * and DB row → API response mapping used by GET /api/projects/discover. + * + * The data source is the `projects` table. Filters supported: + * - free-text query over title/description + * - one or more project statuses + * - required skills (a project must have *all* of them) + * - min / max budget (inclusive) + * + * Sorting is whitelisted through `PROJECT_SORTABLE_FIELDS` so caller-supplied + * values never reach the SQL identifier position. + */ + +import { sql } from '@/lib/db' + +/** Fields that can be used as sort keys. Whitelisted to prevent SQL injection. */ +export const PROJECT_SORTABLE_FIELDS = ['created_at', 'budget', 'deadline'] as const +export type ProjectSortField = (typeof PROJECT_SORTABLE_FIELDS)[number] + +export const PROJECT_SORT_ORDERS = ['asc', 'desc'] as const +export type ProjectSortOrder = (typeof PROJECT_SORT_ORDERS)[number] + +/** Statuses a project can be filtered by. Mirrors lib/projects.ts. */ +export const PROJECT_STATUSES = ['open', 'in_progress', 'completed', 'cancelled'] as const +export type ProjectStatus = (typeof PROJECT_STATUSES)[number] + +/** Pagination bounds. `limit` is clamped to keep responses reasonable. */ +export const PROJECT_DEFAULT_LIMIT = 9 +export const PROJECT_MAX_LIMIT = 50 +export const PROJECT_DEFAULT_PAGE = 1 + +export interface ProjectListing { + id: string + clientId: string + title: string + description: string | null + budgetUsdc: number + status: ProjectStatus + skills: string[] + category: string | null + deadline: string | null + createdAt: string +} + +export interface ListProjectsParams { + /** Free-text query matching title or description (case-insensitive). */ + query: string + /** Selected statuses. Empty = no status filter. */ + statuses: string[] + /** Required skills (a project must have *all* of them). Empty = no filter. */ + skills: string[] + /** Inclusive minimum budget. Null = no lower bound. */ + minBudget: number | null + /** Inclusive maximum budget. Null = no upper bound. */ + maxBudget: number | null + /** Sort column. */ + sort: ProjectSortField + /** Sort direction. */ + order: ProjectSortOrder + /** 1-based page number. */ + page: number + /** Items per page (1..PROJECT_MAX_LIMIT). */ + limit: number +} + +export interface ListProjectsResult { + projects: ProjectListing[] + totalItems: number +} + +export interface ProjectListResponse { + projects: ProjectListing[] + skills: string[] + statuses: readonly string[] + pagination: { + page: number + pageSize: number + totalItems: number + totalPages: number + } +} + +export class ProjectDiscoveryError extends Error { + constructor( + public readonly code: string, + message: string, + ) { + super(message) + this.name = 'ProjectDiscoveryError' + } +} + +interface ProjectRow { + id: string + client_id: string + title: string + description: string | null + budget_usdc: number | string + status: string + skills: string[] | null + category: string | null + deadline: Date | string | null + created_at: Date | string +} + +interface ProjectRowWithCount extends ProjectRow { + total_count: string | number +} + +interface SkillRow { + skill: string | null +} + +function toIso(value: Date | string | null): string | null { + if (value === null || value === undefined) return null + return value instanceof Date ? value.toISOString() : value +} + +/** Convert a DB row to the API listing shape. */ +export function mapProjectRowToListing(row: ProjectRow): ProjectListing { + const budget = + typeof row.budget_usdc === 'number' ? row.budget_usdc : Number(row.budget_usdc) + + return { + id: row.id, + clientId: row.client_id, + title: row.title, + description: row.description ?? null, + budgetUsdc: Number.isFinite(budget) ? budget : 0, + status: row.status as ProjectStatus, + skills: Array.isArray(row.skills) ? row.skills : [], + category: row.category ?? null, + deadline: toIso(row.deadline), + createdAt: toIso(row.created_at) ?? '', + } +} + +/** + * Normalize a query string so it is safe to embed inside a Postgres ILIKE + * pattern (escape `\`, `%` and `_`). + */ +function escapeIlike(value: string): string { + return value.replace(/\\/g, '\\\\').replace(/%/g, '\\%').replace(/_/g, '\\_') +} + +/** + * Build the WHERE fragment in a single sql`` call so every filter is + * parameterised (no string interpolation) while keeping conditional semantics + * via the standard `NULL = NULL OR ` pattern. + */ +function buildWhereFragment(params: ListProjectsParams): ReturnType { + const normalizedQuery = params.query.trim() + const needle = normalizedQuery + ? `%${escapeIlike(normalizedQuery.toLowerCase())}%` + : null + const statusArray = params.statuses.length > 0 ? params.statuses : [] + const skillArray = params.skills.length > 0 ? params.skills : [] + + return sql` + ( + ${needle}::text IS NULL + OR LOWER(p.title) LIKE ${needle} ESCAPE '\\' + OR LOWER(COALESCE(p.description, '')) LIKE ${needle} ESCAPE '\\' + ) + AND ( + cardinality(${statusArray}::text[]) = 0 + OR p.status = ANY(${statusArray}::text[]) + ) + AND ( + cardinality(${skillArray}::text[]) = 0 + OR COALESCE(p.skills, ARRAY[]::text[]) @> ${skillArray}::text[] + ) + AND ( + ${params.minBudget}::numeric IS NULL + OR p.budget_usdc >= ${params.minBudget}::numeric + ) + AND ( + ${params.maxBudget}::numeric IS NULL + OR p.budget_usdc <= ${params.maxBudget}::numeric + ) + ` +} + +/** + * Build a fully-static ORDER BY fragment for the (sort, order) pair. `sort` + * and `order` are pre-validated against a whitelist, so no caller-controlled + * string is interpolated into the SQL identifier position. + */ +function buildOrderBy( + sort: ProjectSortField, + order: ProjectSortOrder, +): ReturnType { + switch (sort) { + case 'budget': + return order === 'asc' + ? sql`p.budget_usdc ASC NULLS LAST, p.id ASC` + : sql`p.budget_usdc DESC NULLS LAST, p.id ASC` + case 'deadline': + return order === 'asc' + ? sql`p.deadline ASC NULLS LAST, p.id ASC` + : sql`p.deadline DESC NULLS LAST, p.id ASC` + case 'created_at': + return order === 'asc' + ? sql`p.created_at ASC NULLS LAST, p.id ASC` + : sql`p.created_at DESC NULLS LAST, p.id ASC` + } +} + +/** + * Lists projects using a single round-trip per page (data + COUNT(*) OVER()), + * falling back to a dedicated COUNT query when the requested page is past the + * end so pagination metadata stays accurate. + */ +export async function listProjects( + params: ListProjectsParams, +): Promise { + const where = buildWhereFragment(params) + const orderBy = buildOrderBy(params.sort, params.order) + const offset = (params.page - 1) * params.limit + + const rows = (await sql` + SELECT + p.id, + p.client_id, + p.title, + p.description, + p.budget_usdc, + p.status, + p.skills, + p.category, + p.deadline, + p.created_at, + COUNT(*) OVER() AS total_count + FROM projects p + WHERE ${where} + ORDER BY ${orderBy} + LIMIT ${params.limit} + OFFSET ${offset} + `) as ProjectRowWithCount[] + + let totalItems: number + if (rows.length > 0) { + const raw = rows[0].total_count + totalItems = typeof raw === 'number' ? raw : parseInt(String(raw), 10) || 0 + } else { + totalItems = await countProjects(params) + } + + const projects = rows.map((row) => { + const { total_count: _ignored, ...rest } = row + void _ignored + return mapProjectRowToListing(rest as ProjectRow) + }) + + return { projects, totalItems } +} + +async function countProjects(params: ListProjectsParams): Promise { + const where = buildWhereFragment(params) + const rows = (await sql` + SELECT COUNT(*) AS count FROM projects p WHERE ${where} + `) as Array<{ count: string | number }> + const value = rows[0]?.count ?? 0 + return typeof value === 'number' ? value : parseInt(String(value), 10) || 0 +} + +/** Returns the distinct skills currently attached to any project. */ +export async function getAvailableSkills(): Promise { + const rows = (await sql` + SELECT DISTINCT skill + FROM projects p, unnest(COALESCE(p.skills, ARRAY[]::text[])) AS skill + ORDER BY skill ASC + `) as SkillRow[] + + return rows + .map((row) => row.skill) + .filter((s): s is string => typeof s === 'string' && s.length > 0) +} + +// ---------- Query parameter parsing & validation --------------------------- + +function parseInteger(value: string | null, fallback: number): number { + if (value === null) return fallback + const parsed = Number.parseInt(value, 10) + return Number.isInteger(parsed) ? parsed : fallback +} + +function parseOptionalBudget(value: string | null, label: string): number | null { + if (value === null || value.trim() === '') return null + const parsed = Number(value) + if (!Number.isFinite(parsed) || parsed < 0) { + throw new ProjectDiscoveryError( + 'INVALID_BUDGET', + `${label} must be a non-negative number`, + ) + } + return parsed +} + +function parseList(raw: string[]): string[] { + const seen = new Set() + const out: string[] = [] + for (const chunk of raw) { + for (const item of chunk.split(',')) { + const trimmed = item.trim() + if (!trimmed) continue + const key = trimmed.toLowerCase() + if (seen.has(key)) continue + seen.add(key) + out.push(trimmed) + } + } + return out +} + +function parseStatuses(raw: string[]): string[] { + const statuses = parseList(raw) + for (const status of statuses) { + if (!(PROJECT_STATUSES as readonly string[]).includes(status)) { + throw new ProjectDiscoveryError( + 'INVALID_STATUS', + `status must be one of: ${PROJECT_STATUSES.join(', ')}`, + ) + } + } + return statuses +} + +function parseSort(value: string | null): ProjectSortField { + const candidate = value ?? 'created_at' + if ((PROJECT_SORTABLE_FIELDS as readonly string[]).includes(candidate)) { + return candidate as ProjectSortField + } + throw new ProjectDiscoveryError( + 'INVALID_SORT_FIELD', + `sort must be one of: ${PROJECT_SORTABLE_FIELDS.join(', ')}`, + ) +} + +function parseOrder(value: string | null): ProjectSortOrder { + const candidate = (value ?? 'desc').toLowerCase() + if ((PROJECT_SORT_ORDERS as readonly string[]).includes(candidate)) { + return candidate as ProjectSortOrder + } + throw new ProjectDiscoveryError( + 'INVALID_SORT_ORDER', + `order must be one of: ${PROJECT_SORT_ORDERS.join(', ')}`, + ) +} + +function parseLimit(value: string | null): number { + const parsed = parseInteger(value, PROJECT_DEFAULT_LIMIT) + if (parsed < 1) { + throw new ProjectDiscoveryError( + 'INVALID_LIMIT', + 'limit must be greater than or equal to 1', + ) + } + return Math.min(parsed, PROJECT_MAX_LIMIT) +} + +function parsePage(value: string | null): number { + const parsed = parseInteger(value, PROJECT_DEFAULT_PAGE) + if (parsed < 1) { + throw new ProjectDiscoveryError( + 'INVALID_PAGE', + 'page must be greater than or equal to 1', + ) + } + return parsed +} + +/** + * Parses and validates the search-parameters from the request URL. Accepts + * `?status=` repeated or comma-separated, `?skills=` repeated or + * comma-separated, and `?q=`/`?query=` for the free-text search. + */ +export function parseDiscoveryParams( + searchParams: URLSearchParams, +): ListProjectsParams { + const query = (searchParams.get('q') ?? searchParams.get('query') ?? '').trim() + const statuses = parseStatuses(searchParams.getAll('status')) + const skills = parseList(searchParams.getAll('skills')) + const minBudget = parseOptionalBudget(searchParams.get('minBudget'), 'minBudget') + const maxBudget = parseOptionalBudget(searchParams.get('maxBudget'), 'maxBudget') + + if (minBudget !== null && maxBudget !== null && minBudget > maxBudget) { + throw new ProjectDiscoveryError( + 'INVALID_BUDGET_RANGE', + 'minBudget cannot be greater than maxBudget', + ) + } + + const sort = parseSort(searchParams.get('sort')) + const order = parseOrder(searchParams.get('order')) + const limit = parseLimit(searchParams.get('limit')) + const page = parsePage(searchParams.get('page')) + + return { query, statuses, skills, minBudget, maxBudget, sort, order, limit, page } +} + +/** + * Builds the JSON response shape for GET /api/projects/discover, including + * pagination metadata and the list of available skills for the filter UI. + */ +export async function buildListResponse( + params: ListProjectsParams, +): Promise { + const result = await listProjects(params) + const skills = await getAvailableSkills() + + const totalItems = result.totalItems + const pageSize = result.projects.length + const totalPages = Math.max(1, Math.ceil(totalItems / params.limit)) + const currentPage = Math.min(params.page, totalPages) + + return { + projects: result.projects, + skills, + statuses: PROJECT_STATUSES, + pagination: { + page: currentPage === 0 ? 1 : currentPage, + pageSize, + totalItems, + totalPages, + }, + } +}