FOUNDRY
C8 Platform
← 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