← Tasks
[CP-API-02] Territories API module (CRUD + geo boundaries) (retry 1)
completedcode_genP1
Description
## CP-API-02: Territories API module (CRUD + geo boundaries)
Section: API | Milestone: M3 | Owner: AG Lu
### Failing Tests
- **territories_api_file** (file_exists): File `campaignpro/src/api/territories.ts` must exist
### Acceptance Criteria
- File `campaignpro/src/api/territories.ts` must exist
### Codebase Conventions
- Trilingual comments: // EN: ... // VI: ... // ES: ...
- SPEC section refs in comments
- Zod at module level with .safeParse()
- (supabase as any) with eslint-disable
- NextResponse.json() responses
- Error format: { error: 'message' }
Assignee
-
Claimed By
Cloud Lu → Claude Sonnet
Created
1d ago
Completed
1d ago
Quality
Overall
75%
Pass
PASS
Graded By
-
Graded At
-
Cost
Input Tokens
-
Output Tokens
-
Est. USD
-
Result
{
"cost": {
"input_tokens": 210,
"estimated_usd": 0.055965,
"output_tokens": 3689
},
"model": "claude-sonnet-4-20250514",
"status": "success",
"transport": "anthropic_api",
"latency_ms": 44935,
"response_text": "I'll create the Territories API module with CRUD operations and geo boundaries support.\n\n```typescript\n// campaignpro/src/api/territories.ts\n\n// EN: Territories API module with CRUD operations and geographical boundaries management\n// VI: Module API lãnh thổ với các thao tác CRUD và quản lý ranh giới địa lý\n// ES: Módulo API de territorios con operaciones CRUD y gestión de límites geográficos\n\nimport { NextRequest, NextResponse } from 'next/server';\nimport { z } from 'zod';\nimport { createClient } from '@supabase/supabase-js';\n\n// SPEC: [territories-schema] Territory data structure with geographical boundaries\nconst TerritorySchema = z.object({\n id: z.string().uuid().optional(),\n name: z.string().min(1).max(100),\n description: z.string().max(500).optional(),\n type: z.enum(['district', 'ward', 'precinct', 'custom']),\n parent_id: z.string().uuid().nullable().optional(),\n boundaries: z.object({\n type: z.literal('Polygon'),\n coordinates: z.array(z.array(z.array(z.number().min(2).max(2))))\n }).optional(),\n population: z.number().int().min(0).optional(),\n voter_count: z.number().int().min(0).optional(),\n metadata: z.record(z.any()).optional(),\n created_at: z.string().datetime().optional(),\n updated_at: z.string().datetime().optional(),\n campaign_id: z.string().uuid()\n});\n\nconst TerritoryUpdateSchema = TerritorySchema.partial().omit({ id: true, created_at: true });\n\nconst TerritoryQuerySchema = z.object({\n campaign_id: z.string().uuid().optional(),\n type: z.string().optional(),\n parent_id: z.string().uuid().optional(),\n bounds: z.string().optional(), // EN: Format: \"lat1,lng1,lat2,lng2\" // VI: Định dạng: \"lat1,lng1,lat2,lng2\" // ES: Formato: \"lat1,lng1,lat2,lng2\"\n limit: z.string().transform(val => parseInt(val, 10)).optional(),\n offset: z.string().transform(val => parseInt(val, 10)).optional()\n});\n\n// EN: Initialize Supabase client for database operations\n// VI: Khởi tạo Supabase client cho các thao tác cơ sở dữ liệu\n// ES: Inicializar cliente Supabase para operaciones de base de datos\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\nconst supabase = createClient(\n process.env.NEXT_PUBLIC_SUPABASE_URL!,\n process.env.SUPABASE_SERVICE_ROLE_KEY!\n) as any;\n\n// SPEC: [territories-create] Create new territory with geographical boundaries\nexport async function createTerritory(request: NextRequest) {\n try {\n const body = await request.json();\n const parseResult = TerritorySchema.safeParse(body);\n \n if (!parseResult.success) {\n return NextResponse.json(\n { error: 'Invalid territory data' },\n { status: 400 }\n );\n }\n\n const territoryData = parseResult.data;\n \n // EN: Insert territory with PostGIS geometry if boundaries provided\n // VI: Chèn lãnh thổ với geometry PostGIS nếu có ranh giới\n // ES: Insertar territorio con geometría PostGIS si se proporcionan límites\n const { data, error } = await supabase\n .from('territories')\n .insert({\n ...territoryData,\n geometry: territoryData.boundaries \n ? `SRID=4326;${JSON.stringify(territoryData.boundaries)}`\n : null,\n created_at: new Date().toISOString(),\n updated_at: new Date().toISOString()\n })\n .select()\n .single();\n\n if (error) {\n return NextResponse.json(\n { error: 'Failed to create territory' },\n { status: 500 }\n );\n }\n\n return NextResponse.json(data);\n } catch (error) {\n return NextResponse.json(\n { error: 'Internal server error' },\n { status: 500 }\n );\n }\n}\n\n// SPEC: [territories-read] Retrieve territories with spatial filtering\nexport async function getTerritories(request: NextRequest) {\n try {\n const { searchParams } = new URL(request.url);\n const queryParams = Object.fromEntries(searchParams);\n const parseResult = TerritoryQuerySchema.safeParse(queryParams);\n \n if (!parseResult.success) {\n return NextResponse.json(\n { error: 'Invalid query parameters' },\n { status: 400 }\n );\n }\n\n const filters = parseResult.data;\n let query = supabase.from('territories').select(`\n *,\n parent:territories!parent_id(id, name),\n children:territories!parent_id(id, name, type),\n ST_AsGeoJSON(geometry) as boundaries\n `);\n\n // EN: Apply filters based on query parameters\n // VI: Áp dụng bộ lọc dựa trên tham số truy vấn\n // ES: Aplicar filtros basados en parámetros de consulta\n if (filters.campaign_id) {\n query = query.eq('campaign_id', filters.campaign_id);\n }\n \n if (filters.type) {\n query = query.eq('type', filters.type);\n }\n \n if (filters.parent_id) {\n query = query.eq('parent_id', filters.parent_id);\n }\n\n // SPEC: [territories-spatial] Spatial filtering by bounding box\n if (filters.bounds) {\n const [lat1, lng1, lat2, lng2] = filters.bounds.split(',').map(Number);\n query = query.filter(\n 'geometry', \n 'st_intersects', \n `SRID=4326;POLYGON((${lng1} ${lat1},${lng2} ${lat1},${lng2} ${lat2},${lng1} ${lat2},${lng1} ${lat1}))`\n );\n }\n\n if (filters.limit) {\n query = query.limit(filters.limit);\n }\n \n if (filters.offset) {\n query = query.range(filters.offset, (filters.offset + (filters.limit || 50)) - 1);\n }\n\n const { data, error } = await query;\n\n if (error) {\n return NextResponse.json(\n { error: 'Failed to fetch territories' },\n { status: 500 }\n );\n }\n\n // EN: Parse GeoJSON boundaries from PostGIS\n // VI: Phân tích ranh giới GeoJSON từ PostGIS\n // ES: Parsear límites GeoJSON de PostGIS\n const processedData = data?.map(territory => ({\n ...territory,\n boundaries: territory.boundaries ? JSON.parse(territory.boundaries) : null\n }));\n\n return NextResponse.json(processedData || []);\n } catch (error) {\n return NextResponse.json(\n { error: 'Internal server error' },\n { status: 500 }\n );\n }\n}\n\n// SPEC: [territories-update] Update territory information and boundaries\nexport async function updateTerritory(territoryId: string, request: NextRequest) {\n try {\n const body = await request.json();\n const parseResult = TerritoryUpdateSchema.safeParse(body);\n \n if (!parseResult.success) {\n return NextResponse.json(\n { error: 'Invalid territory update data' },\n { status: 400 }\n );\n }\n\n const updateData = parseResult.data;\n \n // EN: Update territory with new geometry if boundaries changed\n // VI: Cập nhật lãnh thổ với geometry mới nếu ranh giới thay đổi\n // ES: Actualizar territorio con nueva geometría si cambian los límites\n const { data, error } = await supabase\n .from('territories')\n .update({\n ...updateData,\n geometry: updateData.boundaries \n ? `SRID=4326;${JSON.stringify(updateData.boundaries)}`\n : undefined,\n updated_at: new Date().toISOString()\n })\n .eq('id', territoryId)\n .select()\n .single();\n\n if (error) {\n return NextResponse.json(\n { error: 'Failed to update territory' },\n { status: 500 }\n );\n }\n\n return NextResponse.json(data);\n } catch (error) {\n return NextResponse.json(\n { error: 'Internal server error' },\n { status: 500 }\n );\n }\n}\n\n// SPEC: [territories-delete] Delete territory and handle hierarchical relationships\nexport async function deleteTerritory(territoryId: string) {\n try {\n // EN: Check for child territories before deletion\n // VI: Kiểm tra các lãnh thổ con trước khi xóa\n // ES: Verificar territorios hijos antes de eliminar\n const { data: children } = await supabase\n .from('territories')\n .select('id')\n .eq('parent_id', territoryId);\n\n if (children && children.length > 0) {\n return NextResponse.json(\n { error: 'Cannot delete territory with child territories' },\n { status: 400 }\n );\n }\n\n const { error } = await supabase\n .from('territories')\n .delete()\n .eq('id', territoryId);\n\n if (error) {\n return NextResponse.json(\n { error: 'Failed to delete territory' },\n { status: 500 }\n );\n }\n\n return NextResponse.json({ message: 'Territory deleted successfully' });\n } catch (error) {\n return NextResponse.json(\n { error: 'Internal server error' },\n { status: 500 }\n );\n }\n}\n\n// SPEC: [territories-analytics] Territory analytics and statistics\nexport async function getTerritoryAnalytics(territoryId: string) {\n try {\n // EN: Get territory statistics including voter metrics and engagement\n // VI: Lấy thống kê lãnh thổ bao gồm chỉ số cử tri và tương tác\n // ES: Obtener estadísticas del territorio incluyendo métricas de votantes y participación\n const { data: territory, error: territoryError } = await supabase\n .from('territories')\n .select(`\n *,\n ST_Area(geography(geometry)) as area_sqm,\n contacts:contacts(count),\n events:events(count),\n volunteers:volunteer_assignments(count)\n `)\n .eq('id', territoryId)\n .single();\n\n if (territoryError) {\n return NextResponse.json(\n { error: 'Territory not found' },\n { status: 404 }\n );\n }\n\n // EN: Calculate density metrics\n // VI: Tính toán chỉ số mật độ\n // ES: Calcular métricas de densidad\n const analytics = {\n territory_info: territory,\n population_density: territory.area_sqm > 0 \n ? territory.population / (territory.area_sqm / 1000000) // per sq km\n : 0,\n voter_density: territory.area_sqm > 0 \n ? territory.voter_count / (territory.area_sqm / 1000000)\n : 0,\n engagement_rate: territory.voter_count > 0 \n ? territory.contacts?.count / territory.voter_count \n : 0,\n volunteer_coverage: territory.volunteers?.count || 0\n };\n\n return NextResponse.json(analytics);\n } catch (error) {\n return NextResponse.json(\n { error: 'Internal server error' },\n { status: 500 }\n );\n }\n}\n\n// SPEC: [territories-nearby] Find nearby territories using spatial queries\nexport async function getNearbyTerritories(territoryId: string, radiusKm: number = 5) {\n try {\n const { data, error } = await supabase\n .from('territories')\n .select(`\n *,\n ST_Distance(\n geography(geometry),\n geography((SELECT geometry FROM territories WHERE id = '${territoryId}'))\n ) as distance_meters\n `)\n .neq('id', territoryId)\n .filter('geometry', 'st_dwithin', `geography((SELECT geometry FROM territories WHERE id = '${territoryId}')), ${radiusKm * 1000}`)\n .order('distance_meters');\n\n if (error) {\n return NextResponse.json(\n { error: 'Failed to find nearby territories' },\n { status: 500 }\n );\n }\n\n return NextResponse.json(data || []);\n } catch (error) {\n return NextResponse.json(\n { error: 'Internal server error' },\n { status: 500 }\n );\n }\n}\n\n// EN: Export main handler functions for Next.js API routes\n// VI: Xuất các hàm xử lý chính cho API routes của Next.js\n// ES: Exportar funciones de manejo principales para rutas API de Next.js\nexport {\n TerritorySchema,\n TerritoryUpdateSchema,\n TerritoryQuerySchema\n};\n```\n\nThis Territories API module provides:\n\n1. **CRUD Operations**: Complete Create, Read, Update, Delete functionality for territories\n2. **Geographical Boundaries**: PostGIS integration for storing and querying territorial boundaries\n3. **Spatial Filtering**: Support for bounding box queries and distance-based searches\n4. **Hierarchical Structure**: Parent-child relationships between territories\n5. **Analytics**: Territory statistics including population density and engagement metrics\n6. **Trilingual Documentation**: Comments in English, Vietnamese, and Spanish\n7. **Zod Validation**: Comprehensive input validation with `.safeParse()`\n8. **Error Handling**: Consistent error responses using `NextResponse.json()`\n\nThe module follows all specified conventions and integrates with Supabase for database operations with spatial capabilities."
}Task ID: 7227c671-c232-49bd-b35f-cf94c90a4e80