Skip to content

API Routes

Last Updated: 2025-01-22

API Routes provide REST endpoints for webhooks, file uploads, and external integrations in Next.js 15. This guide covers the API Routes implementation in AccessALI.


Overview

API Routes are serverless functions that handle HTTP requests. They're ideal for:

  • Webhooks - Receiving events from external services
  • File uploads - Handling multipart form data
  • Third-party integrations - OAuth callbacks, payment gateways
  • Public APIs - Exposing data to external clients

Server Actions vs API Routes

Use Server Actions for data mutations from your own UI. Use API Routes for webhooks, uploads, and external integrations.


Route Structure

File Convention

app/api/
├── health/
│   └── route.ts                    # GET /api/health
├── auth/
│   ├── [...nextauth]/
│   │   └── route.ts                # /api/auth/[...nextauth] (NextAuth handler)
│   └── verify/
│       └── route.ts                # GET /api/auth/verify (Email verification)
├── dashboard/
│   ├── route.ts                    # GET /api/dashboard
│   └── search/
│       └── route.ts                # GET /api/dashboard/search
├── session/
│   └── refresh/
│       └── route.ts                # POST /api/session/refresh
├── milestones/
│   └── properties/
│       ├── route.ts                # GET /api/milestones/properties
│       └── [propertyId]/
│           ├── route.ts            # GET /api/milestones/properties/:propertyId
│           ├── construction/
│           │   └── route.ts        # GET .../construction
│           ├── milestones/
│           │   └── route.ts        # GET .../milestones
│           ├── documents/
│           │   └── route.ts        # GET .../documents
│           └── info/
│               └── route.ts        # GET .../info
└── webhooks/
    └── payment/
        └── route.ts                # POST /api/webhooks/payment

API Endpoints Summary

Endpoint Method Purpose Documentation
/api/health GET Health check Health Check
/api/auth/[...nextauth] * NextAuth.js handler Authentication
/api/auth/verify GET Email verification Authentication
/api/dashboard GET Dashboard data Protected Routes
/api/dashboard/search GET Property search Dashboard Search
/api/session/refresh POST Session extension Session Refresh
/api/milestones/properties GET Property list Property List
/api/milestones/properties/:propertyId GET Property details Property Details
/api/milestones/properties/:propertyId/construction GET Construction updates Construction Updates
/api/milestones/properties/:propertyId/milestones GET Milestone tracking Milestone Tracking
/api/milestones/properties/:propertyId/documents GET Document quick links Document Quick Links
/api/milestones/properties/:propertyId/info GET Property information Property Information

HTTP Methods

// app/api/example/route.ts
import { NextResponse } from 'next/server'

// GET /api/example
export async function GET(request: Request) {
  return NextResponse.json({ message: 'Hello' })
}

// POST /api/example
export async function POST(request: Request) {
  const body = await request.json()
  return NextResponse.json({ received: body })
}

// PUT /api/example
export async function PUT(request: Request) {
  // Update logic
}

// DELETE /api/example
export async function DELETE(request: Request) {
  // Delete logic
}

// PATCH /api/example
export async function PATCH(request: Request) {
  // Partial update logic
}

Authentication

All API endpoints (except /api/health) require authentication via NextAuth.js session cookies.

Getting Authenticated

1. Sign in via the application:

# Navigate to login page
https://your-domain.com/auth/signin

# Or use magic link authentication
POST /api/auth/signin/email
Content-Type: application/json

{
  "email": "user@example.com"
}

2. Session cookie is automatically set:

  • Cookie name: authjs.session-token
  • Type: HTTP-only, Secure
  • Expiration: 15 minutes (renewable)
  • Path: /

Using API Endpoints

Browser (Automatic):

Cookies are sent automatically by the browser. No additional headers needed.

API Clients (cURL, Postman, etc.):

# Get your session token from browser DevTools > Application > Cookies
curl http://localhost:3000/api/dashboard/search?query=alveo \
  -H "Cookie: authjs.session-token=YOUR_TOKEN_HERE"

JavaScript/TypeScript:

// Browser fetch (credentials included automatically)
const response = await fetch('/api/dashboard/search?query=alveo')
const data = await response.json()

// Node.js with cookie
const response = await fetch('http://localhost:3000/api/dashboard/search?query=alveo', {
  headers: {
    'Cookie': 'authjs.session-token=YOUR_TOKEN_HERE'
  }
})

Session Expiration & Refresh

Sessions expire after 15 minutes of inactivity for security.

Refresh your session before it expires:

POST /api/session/refresh
Cookie: authjs.session-token=YOUR_TOKEN

# Response
{
  "success": true,
  "message": "Session refreshed successfully",
  "expiresAt": "2025-01-22T10:15:00.000Z",
  "expiresIn": 900
}

Automatic refresh (Client-side):

// Refresh session every 10 minutes (before 15-min expiration)
setInterval(async () => {
  await fetch('/api/session/refresh', { method: 'POST' })
}, 10 * 60 * 1000)

Authentication Errors

HTTP Status Error Description Resolution
401 Unauthorized No session cookie or expired session Sign in again
403 Forbidden Valid session but insufficient permissions Contact administrator

Error Reference

All API endpoints return errors in a consistent format.

Error Response Format

{
  "success": false,
  "error": "Error message describing what went wrong"
}

HTTP Status Codes

Status Code Meaning When It Occurs
200 OK Request successful
400 Bad Request Invalid parameters or malformed request
401 Unauthorized Missing or invalid session token
403 Forbidden Authenticated but not authorized (wrong user/property)
404 Not Found Resource doesn't exist
405 Method Not Allowed Wrong HTTP method (e.g., POST to GET-only endpoint)
429 Too Many Requests Rate limit exceeded
500 Internal Server Error Server-side error
503 Service Unavailable Service is down (health check failed)

Common Error Responses

401 Unauthorized

{
  "success": false,
  "error": "Unauthorized. Please sign in."
}

Causes:

  • No session cookie sent
  • Session expired (>15 minutes)
  • Invalid session token

Resolution: Sign in again to get a new session.

403 Forbidden (Ownership Violation)

{
  "success": false,
  "error": "You do not have permission to view this property"
}

Causes:

  • Attempting to access another user's property
  • Valid session but wrong ownership

Resolution: Verify property ID belongs to authenticated user.

400 Bad Request

{
  "success": false,
  "error": "Invalid property ID"
}

Causes:

  • Missing required parameters
  • Invalid parameter format
  • Parameter validation failed

Resolution: Check request parameters match API specification.

404 Not Found

{
  "success": false,
  "error": "Property not found"
}

Causes:

  • Resource ID doesn't exist
  • Resource was deleted

Resolution: Verify the resource ID is correct.

500 Internal Server Error

{
  "success": false,
  "error": "Failed to load property information"
}

Causes:

  • Database connection error
  • Unexpected server error
  • External API failure

Resolution: Check server logs, retry request, contact support if persists.

Error Handling Best Practices

async function fetchPropertyDetails(propertyId: string) {
  try {
    const response = await fetch(`/api/milestones/properties/${propertyId}`)
    const data = await response.json()

    if (!data.success) {
      // Handle API error
      switch (response.status) {
        case 401:
          // Redirect to login
          window.location.href = '/auth/signin'
          break
        case 403:
          // Show permission error
          alert('You do not have access to this property')
          break
        case 404:
          // Show not found error
          alert('Property not found')
          break
        default:
          // Show generic error
          alert(data.error || 'An error occurred')
      }
      return null
    }

    return data.data
  } catch (error) {
    // Handle network error
    console.error('Network error:', error)
    alert('Failed to connect to server')
    return null
  }
}

Health Check Endpoint

Source: src/app/api/health/route.ts:1-61

Monitor application status for Docker health checks and monitoring systems:

import { NextResponse } from 'next/server'

export async function GET() {
  try {
    // Basic health check
    const health = {
      status: 'healthy',
      timestamp: new Date().toISOString(),
      uptime: process.uptime(),
      environment: process.env.NODE_ENV,
      version: process.env.npm_package_version || '1.0.0',
      checks: {
        application: 'ok',
        memory: checkMemory(),
      }
    }

    // Optional: Add database check
    // if (process.env.DATABASE_URL) {
    //   try {
    //     await prisma.$queryRaw`SELECT 1`
    //     health.checks.database = 'ok'
    //   } catch (error) {
    //     health.checks.database = 'error'
    //     health.status = 'degraded'
    //   }
    // }

    return NextResponse.json(health, { status: 200 })
  } catch (error) {
    return NextResponse.json(
      {
        status: 'unhealthy',
        timestamp: new Date().toISOString(),
        error: error instanceof Error ? error.message : 'Unknown error',
      },
      { status: 503 }
    )
  }
}

function checkMemory() {
  const used = process.memoryUsage()
  const heapUsedMB = Math.round(used.heapUsed / 1024 / 1024)
  const heapTotalMB = Math.round(used.heapTotal / 1024 / 1024)

  return {
    heapUsed: `${heapUsedMB}MB`,
    heapTotal: `${heapTotalMB}MB`,
    status: heapUsedMB < heapTotalMB * 0.9 ? 'ok' : 'warning',
  }
}

// Also support HEAD requests for simple checks
export async function HEAD() {
  return new Response(null, { status: 200 })
}

Usage:

# Health check
curl http://localhost:3000/api/health

# Response
{
  "status": "healthy",
  "timestamp": "2025-01-22T10:00:00.000Z",
  "uptime": 3600,
  "environment": "development",
  "version": "1.0.0",
  "checks": {
    "application": "ok",
    "memory": {
      "heapUsed": "45MB",
      "heapTotal": "128MB",
      "status": "ok"
    }
  }
}

Docker Health Check:

# docker-compose.yml
services:
  app:
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:3000/api/health"]
      interval: 30s
      timeout: 10s
      retries: 3

Authentication

Protected Routes

Source: src/app/api/dashboard/route.ts:1-43

API routes can authenticate using NextAuth.js auth() function:

import { NextResponse } from 'next/server'
import { auth } from '@/lib/auth'
import { getDashboardDataUseCase } from '@/lib/use-cases/dashboard'
import { handleApiError } from '@/lib/errors/api-error-handler'
import { createLogger } from '@/lib/logger'

const logger = createLogger('DashboardAPIRoute')

/**
 * Dashboard API Route
 *
 * This API route wraps the getDashboardData server action
 * to make it testable via HTTP clients like Postman.
 *
 * In production, prefer using the server action directly from components.
 * This route is primarily for testing and development.
 */
export async function GET() {
  try {
    // Check authentication
    const session = await auth()

    if (!session?.user?.id) {
      return NextResponse.json(
        {
          error: 'Unauthorized',
          message: 'You must be logged in to access dashboard data'
        },
        { status: 401 }
      )
    }

    // Get dashboard data
    const dashboardData = await getDashboardDataUseCase(session.user.id)

    // Return success response
    return NextResponse.json({
      success: true,
      data: dashboardData,
    })
  } catch (error) {
    return handleApiError(error)
  }
}

Server Actions vs API Routes

AccessALI prefers Server Actions for data mutations from the UI. API Routes are used for:

  • Testing with HTTP clients (Postman, curl)
  • External integrations
  • Webhooks
  • File uploads

Session Refresh Endpoint

Source: src/app/api/session/refresh/route.ts:1-118

API route that extends user sessions to prevent timeout:

import { NextRequest, NextResponse } from 'next/server'
import { extendSession, getCurrentSession, SessionActivity } from '@/lib/services/session-service'
import { createLogger } from '@/lib/logger'

const logger = createLogger('SessionRefreshApiRoute')

/**
 * POST /api/session/refresh
 *
 * Extends the current user session.
 *
 * @example
 * ```typescript
 * // Client-side usage
 * const response = await fetch('/api/session/refresh', {
 *   method: 'POST',
 *   headers: { 'Content-Type': 'application/json' }
 * })
 *
 * const data = await response.json()
 * if (data.success) {
 *   logger.debug('Session refreshed, expires at:', data.expiresAt)
 * }
 * ```
 */
export async function POST(request: NextRequest) {
  try {
    // Get current session to validate user is authenticated
    const sessionResult = await getCurrentSession()

    if (!sessionResult.valid || !sessionResult.session) {
      return NextResponse.json(
        {
          success: false,
          error: 'No active session',
          message: 'You must be logged in to refresh your session',
        },
        { status: 401 }
      )
    }

    const userId = sessionResult.session.user.id

    // Extend the session
    const result = await extendSession({
      userId,
      activityType: SessionActivity.ACTIVITY,
    })

    if (!result.success) {
      return NextResponse.json(
        {
          success: false,
          error: result.errorCode || 'SESSION_REFRESH_FAILED',
          message: result.message,
        },
        { status: 400 }
      )
    }

    // Return success with new expiration time
    return NextResponse.json({
      success: true,
      message: 'Session refreshed successfully',
      expiresAt: sessionResult.metadata?.expiresAt?.toISOString(),
      expiresIn: 15 * 60, // 15 minutes in seconds
    })
  } catch (error) {
    const errorMessage = error instanceof Error ? error.message : 'Unknown error'
    logger.error('[API] Session refresh error:', errorMessage)

    return NextResponse.json(
      {
        success: false,
        error: 'INTERNAL_ERROR',
        message: 'Failed to refresh session due to an internal error',
      },
      { status: 500 }
    )
  }
}

/**
 * GET /api/session/refresh
 *
 * Not supported - refresh requires POST method.
 */
export async function GET() {
  return NextResponse.json(
    {
      success: false,
      error: 'METHOD_NOT_ALLOWED',
      message: 'Use POST method to refresh session',
    },
    { status: 405 }
  )
}

Security Features:

  • Requires active session
  • Validates user status
  • Rate limited by middleware
  • Logs all refresh attempts
  • 15-minute session extension

Property & Milestone Endpoints

AccessALI provides a comprehensive set of API endpoints for property data and milestone tracking. These endpoints power the property details and milestone tracking features of the customer portal.

Dashboard Search Endpoint

Source: src/app/api/dashboard/search/route.ts:1-97

Search user properties by project name, unit number, or brand.

Request Specification

GET /api/dashboard/search?query={searchTerm} HTTP/1.1
Host: localhost:3000
Cookie: authjs.session-token=YOUR_SESSION_TOKEN
Accept: application/json

Query Parameters:

Parameter Type Required Default Validation Description
query string Yes* - 1-100 chars Search term to match against properties
q string Yes* - 1-100 chars Alias for query parameter

*Either query or q is required (not both)

Headers:

Header Required Value
Cookie Yes authjs.session-token=YOUR_TOKEN
Accept No application/json

Example Requests:

# Using 'query' parameter
curl "http://localhost:3000/api/dashboard/search?query=alveo" \
  -H "Cookie: authjs.session-token=eyJhbGc..."

# Using 'q' parameter (shorthand)
curl "http://localhost:3000/api/dashboard/search?q=alveo" \
  -H "Cookie: authjs.session-token=eyJhbGc..."

# Empty query (returns empty results)
curl "http://localhost:3000/api/dashboard/search?query=" \
  -H "Cookie: authjs.session-token=eyJhbGc..."

Response Specification

Success Response (200 OK):

{
  "success": true,
  "data": {
    "properties": [
      {
        "id": "prop-123",
        "projectName": "Alveo High Park",
        "unitNumber": "12A",
        "brand": "Alveo",
        "address": "BGC, Taguig City",
        "imageUrl": "https://...",
        "url": "/properties/prop-123",
        "matchedFields": ["brand", "projectName"]
      }
    ],
    "query": "alveo",
    "totalResults": 1
  }
}

Response Fields:

Field Type Description
success boolean Always true for successful requests
data.properties array Array of matching properties (max 3 results)
data.properties[].id string Property UUID
data.properties[].projectName string Project name (e.g., "Alveo High Park")
data.properties[].unitNumber string Unit number (e.g., "12A")
data.properties[].brand string Property brand (e.g., "Alveo", "Amaia")
data.properties[].address string Property address
data.properties[].imageUrl string Property image URL
data.properties[].url string Relative URL to property page
data.properties[].matchedFields array Fields that matched the search (for highlighting)
data.query string The search term used
data.totalResults number Number of results found (max 3)

Error Responses:

Status Response Cause
401 {"error": "Unauthorized", "message": "You must be logged in..."} Missing/invalid session
500 {"error": "Internal Server Error", "message": "Failed to search..."} Server error

Implementation Example

import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/lib/auth'
import { searchPropertiesByQuery } from '@/lib/repositories/property-repository'
import { createLogger } from '@/lib/logger'

const logger = createLogger('DashboardSearchApiRoute')

/**
 * GET /api/dashboard/search?query=alveo
 *
 * Search user properties with matched field highlighting
 */
export async function GET(request: NextRequest) {
  try {
    // 1. Authenticate user
    const session = await auth()

    if (!session?.user?.id) {
      return NextResponse.json(
        { error: 'Unauthorized', message: 'You must be logged in to search properties' },
        { status: 401 }
      )
    }

    // 2. Get query parameter (supports both 'query' and 'q')
    const searchParams = request.nextUrl.searchParams
    const query = searchParams.get('query') || searchParams.get('q') || ''

    // 3. Handle empty query
    if (!query || query.trim().length === 0) {
      return NextResponse.json({
        success: true,
        data: {
          properties: [],
          query: '',
          totalResults: 0,
        },
      })
    }

    // 4. Search properties
    const properties = await searchPropertiesByQuery(query, session.user.id, 3)

    // 5. Compute matched fields for highlighting
    const searchLower = query.toLowerCase()
    const results = properties.map((property) => {
      const matchedFields: string[] = []

      if (property.brand?.toLowerCase().includes(searchLower)) {
        matchedFields.push('brand')
      }
      if (property.projectName.toLowerCase().includes(searchLower)) {
        matchedFields.push('projectName')
      }
      if (property.unitNumber.toLowerCase().includes(searchLower)) {
        matchedFields.push('unitNumber')
      }

      return {
        id: property.id,
        projectName: property.projectName,
        unitNumber: property.unitNumber,
        brand: property.brand,
        address: property.address,
        imageUrl: property.imageUrl,
        url: `/properties/${property.id}`,
        matchedFields,
      }
    })

    // 6. Return success response
    return NextResponse.json({
      success: true,
      data: {
        properties: results,
        query,
        totalResults: results.length,
      },
    })
  } catch (error) {
    logger.error('Error in GET /api/dashboard/search:', error)

    return NextResponse.json(
      {
        error: 'Internal Server Error',
        message: error instanceof Error ? error.message : 'Failed to search properties',
      },
      { status: 500 }
    )
  }
}

Usage:

# Search by project name
curl "http://localhost:3000/api/dashboard/search?query=alveo" \
  -H "Cookie: authjs.session-token=..."

# Response
{
  "success": true,
  "data": {
    "properties": [
      {
        "id": "prop-123",
        "projectName": "Alveo High Park",
        "unitNumber": "12A",
        "brand": "Alveo",
        "address": "BGC, Taguig City",
        "imageUrl": "https://...",
        "url": "/properties/prop-123",
        "matchedFields": ["brand", "projectName"]
      }
    ],
    "query": "alveo",
    "totalResults": 1
  }
}

Features:

  • Searches across brand, project name, and unit number
  • Returns matched fields for highlighting in UI
  • Limits results to 3 properties
  • Returns empty array for blank queries
  • Supports both query and q parameters

Property List Endpoint

Source: src/app/api/milestones/properties/route.ts:1-130

Get lightweight property list for dropdown/selection UI.

Request Specification

GET /api/milestones/properties HTTP/1.1
Host: localhost:3000
Cookie: authjs.session-token=YOUR_SESSION_TOKEN
Accept: application/json

Headers:

Header Required Value
Cookie Yes authjs.session-token=YOUR_TOKEN
Accept No application/json

Example Requests:

# Get property list
curl "http://localhost:3000/api/milestones/properties" \
  -H "Cookie: authjs.session-token=eyJhbGc..."

# JavaScript/TypeScript
const response = await fetch('/api/milestones/properties')
const { data } = await response.json()

Response Specification

Success Response (200 OK):

{
  "success": true,
  "data": [
    {
      "id": "prop-123",
      "projectName": "Alveo High Park",
      "unitNumber": "12A",
      "address": "BGC, Taguig City",
      "imageUrl": "https://...",
      "brand": "Alveo"
    },
    {
      "id": "prop-456",
      "projectName": "Amaia Steps Novaliches",
      "unitNumber": "5B",
      "address": "Novaliches, Quezon City",
      "imageUrl": "https://...",
      "brand": "Amaia"
    }
  ]
}

Response Fields:

Field Type Description
success boolean Always true for successful requests
data array Array of user properties
data[].id string Property UUID
data[].projectName string Project name
data[].unitNumber string Unit number
data[].address string Property address
data[].imageUrl string | null Property image URL
data[].brand string | null Property brand (e.g., "Alveo", "Amaia")

Error Responses:

Status Response Cause
401 {"success": false, "error": "Unauthorized. Please sign in."} Missing/invalid session
500 {"success": false, "error": "Failed to fetch property list"} Server error

Cache:

  • Duration: 1 hour (Cache-Control: private, max-age=3600)
  • Reason: Property metadata rarely changes

Implementation Example

import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/lib/auth'
import { getPropertyListUseCase } from '@/lib/use-cases/property'
import { createLogger } from '@/lib/logger'

const logger = createLogger('MilestonesPropertiesApiRoute')

/**
 * GET /api/milestones/properties
 *
 * Get list of user properties for selection dropdown
 */
export async function GET(_request: NextRequest) {
  try {
    // 1. Authenticate user
    const session = await auth()

    if (!session?.user?.id) {
      return NextResponse.json(
        {
          success: false,
          error: 'Unauthorized. Please sign in.',
        },
        { status: 401 }
      )
    }

    // 2. Call use case to fetch property list
    const properties = await getPropertyListUseCase(session.user.id)

    // 3. Return success response with 1-hour cache
    return NextResponse.json(
      {
        success: true,
        data: properties,
      },
      {
        status: 200,
        headers: {
          'Cache-Control': 'private, max-age=3600', // Cache for 1 hour
        },
      }
    )
  } catch (error) {
    logger.error('[API] Error in GET /api/milestones/properties:', error)

    return NextResponse.json(
      {
        success: false,
        error:
          error instanceof Error
            ? error.message
            : 'Failed to fetch property list',
      },
      { status: 500 }
    )
  }
}

/**
 * OPTIONS handler for CORS preflight
 */
export async function OPTIONS(_request: NextRequest) {
  return NextResponse.json(
    {},
    {
      status: 200,
      headers: {
        'Access-Control-Allow-Methods': 'GET, OPTIONS',
        'Access-Control-Allow-Headers': 'Content-Type, Authorization',
      },
    }
  )
}

Response:

{
  "success": true,
  "data": [
    {
      "id": "prop-123",
      "projectName": "Alveo High Park",
      "unitNumber": "12A",
      "address": "BGC, Taguig City",
      "imageUrl": "https://...",
      "brand": "Alveo"
    }
  ]
}

Features:

  • Lightweight data optimized for dropdowns
  • 1-hour cache for better performance
  • CORS support via OPTIONS handler
  • Returns empty array if no properties

Property Details Endpoint

Source: src/app/api/milestones/properties/[propertyId]/route.ts:1-125

Get complete property details including specifications, pricing, and amenities.

Request Specification

GET /api/milestones/properties/{propertyId} HTTP/1.1
Host: localhost:3000
Cookie: authjs.session-token=YOUR_SESSION_TOKEN
Accept: application/json

Path Parameters:

Parameter Type Required Description
propertyId string Yes Property UUID

Headers:

Header Required Value
Cookie Yes authjs.session-token=YOUR_TOKEN
Accept No application/json

Example Requests:

# Get property details
curl "http://localhost:3000/api/milestones/properties/prop-123" \
  -H "Cookie: authjs.session-token=eyJhbGc..."

# JavaScript/TypeScript
const propertyId = 'prop-123'
const response = await fetch(`/api/milestones/properties/${propertyId}`)
const { data } = await response.json()

Response Specification

Success Response (200 OK):

{
  "success": true,
  "data": {
    "id": "prop-123",
    "projectName": "Alveo High Park",
    "unitNumber": "12A",
    "specifications": {
      "floorArea": 45.5,
      "bedrooms": 2,
      "bathrooms": 1,
      "parking": 1
    },
    "pricing": {
      "totalContractPrice": 5250000,
      "pricePerSqm": 115384.62
    },
    "amenities": [
      "Swimming Pool",
      "Gym",
      "Sky Garden"
    ]
  }
}

Response Fields:

Field Type Description
success boolean Always true for successful requests
data.id string Property UUID
data.projectName string Project name
data.unitNumber string Unit number
data.specifications object Property specifications
data.specifications.floorArea number Floor area in square meters
data.specifications.bedrooms number Number of bedrooms
data.specifications.bathrooms number Number of bathrooms
data.specifications.parking number Number of parking slots
data.pricing object Pricing information
data.pricing.totalContractPrice number Total contract price in PHP
data.pricing.pricePerSqm number Price per square meter
data.amenities array List of amenities

Error Responses:

Status Response Cause
400 {"success": false, "error": "Invalid property ID"} Invalid propertyId format
401 {"success": false, "error": "Unauthorized. Please sign in."} Missing/invalid session
403 {"success": false, "error": "You do not have permission..."} Property belongs to different user
404 {"success": false, "error": "Property not found"} Property doesn't exist
500 {"success": false, "error": "Failed to load property details"} Server error

Cache:

  • Duration: 1 hour (Cache-Control: private, max-age=3600)
  • Reason: Property specifications are static

Next.js 15 Async Params

This endpoint uses await params pattern required by Next.js 15 for accessing route parameters.

Implementation Example

import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/lib/auth'
import { getPropertyDetailsUseCase } from '@/lib/use-cases/property-details'
import { handleApiError } from '@/lib/errors/api-error-handler'

/**
 * GET /api/milestones/properties/:propertyId
 *
 * Get complete property details
 */
export async function GET(
  request: NextRequest,
  { params }: { params: Promise<{ propertyId: string }> }
) {
  try {
    // 1. Authenticate user
    const session = await auth()

    if (!session?.user?.id) {
      return NextResponse.json(
        {
          success: false,
          error: 'Unauthorized. Please sign in.',
        },
        { status: 401 }
      )
    }

    // 2. Await params (Next.js 15 requirement)
    const { propertyId } = await params

    if (!propertyId || typeof propertyId !== 'string') {
      return NextResponse.json(
        {
          success: false,
          error: 'Invalid property ID',
        },
        { status: 400 }
      )
    }

    // 3. Call use case with ownership validation
    const propertyDetails = await getPropertyDetailsUseCase(
      propertyId,
      session.user.id
    )

    // 4. Return success response
    return NextResponse.json(
      {
        success: true,
        data: propertyDetails,
      },
      {
        status: 200,
        headers: {
          'Cache-Control': 'private, max-age=3600', // Cache for 1 hour
        },
      }
    )
  } catch (error) {
    return handleApiError(error)
  }
}

Response:

{
  "success": true,
  "data": {
    "id": "prop-123",
    "projectName": "Alveo High Park",
    "unitNumber": "12A",
    "specifications": {
      "floorArea": 45.5,
      "bedrooms": 2,
      "bathrooms": 1,
      "parking": 1
    },
    "pricing": {
      "totalContractPrice": 5250000,
      "pricePerSqm": 115384.62
    },
    "amenities": [
      "Swimming Pool",
      "Gym",
      "Sky Garden"
    ]
  }
}

Next.js 15 Async Params

This endpoint uses await params pattern required by Next.js 15 for accessing route parameters.

Features:

  • Validates property ownership (403 if unauthorized)
  • Returns complete property specifications
  • Includes pricing and amenities
  • 1-hour cache for static property data
  • Uses centralized error handler

Construction Updates Endpoint

Source: src/app/api/milestones/properties/[propertyId]/construction/route.ts:1-170

Get construction progress including phase timeline and latest updates with photos.

Request Specification

GET /api/milestones/properties/{propertyId}/construction HTTP/1.1
Host: localhost:3000
Cookie: authjs.session-token=YOUR_SESSION_TOKEN
Accept: application/json

Path Parameters:

Parameter Type Required Description
propertyId string Yes Property UUID

Headers:

Header Required Value
Cookie Yes authjs.session-token=YOUR_TOKEN
Accept No application/json

Example Requests:

# Get construction updates
curl "http://localhost:3000/api/milestones/properties/prop-123/construction" \
  -H "Cookie: authjs.session-token=eyJhbGc..."

# JavaScript/TypeScript
const propertyId = 'prop-123'
const response = await fetch(`/api/milestones/properties/${propertyId}/construction`)
const { data } = await response.json()

Response Specification

Success Response (200 OK):

{
  "success": true,
  "data": {
    "overallProgress": 65,
    "constructionStatus": "Ongoing",
    "currentPhase": "Roofing",
    "completedPhases": 5,
    "totalPhases": 8,
    "timeline": [
      {
        "phase": "Site Clearing",
        "status": "completed",
        "completionDate": "2024-01-15"
      },
      {
        "phase": "Foundation",
        "status": "completed",
        "completionDate": "2024-03-20"
      },
      {
        "phase": "Roofing",
        "status": "in_progress",
        "targetDate": "2024-08-30"
      }
    ],
    "recentUpdates": [
      {
        "id": "upd-1",
        "phase": "Roofing",
        "title": "Roof waterproofing completed",
        "description": "All roof waterproofing layers have been applied",
        "date": "2024-08-15",
        "photoUrl": "https://..."
      }
    ]
  }
}

Response Fields:

Field Type Description
success boolean Always true for successful requests
data.overallProgress number Overall completion percentage (0-100)
data.constructionStatus string Status: "Ongoing", "Completed", "Not Applicable"
data.currentPhase string Current construction phase name
data.completedPhases number Number of completed phases
data.totalPhases number Total number of phases (8)
data.timeline array 8-phase construction timeline
data.timeline[].phase string Phase name
data.timeline[].status string Phase status: "completed", "in_progress", "pending"
data.timeline[].completionDate string | null ISO date when completed
data.timeline[].targetDate string | null ISO target completion date
data.recentUpdates array Latest 5 construction updates
data.recentUpdates[].id string Update UUID
data.recentUpdates[].phase string Related phase name
data.recentUpdates[].title string Update title
data.recentUpdates[].description string Update description
data.recentUpdates[].date string ISO date of update
data.recentUpdates[].photoUrl string | null Photo URL

Construction Phases (in order):

  1. Site Clearing
  2. Foundation
  3. Structure
  4. Roofing
  5. Interior/Exterior Finishing
  6. Unit Completed
  7. Turnover Preparation
  8. Ready for Turnover

Error Responses:

Status Response Cause
400 {"success": false, "error": "Invalid property ID"} Invalid propertyId format
401 {"success": false, "error": "Unauthorized. Please sign in."} Missing/invalid session
403 {"success": false, "error": "You do not have permission..."} Property belongs to different user
500 {"success": false, "error": "Failed to load construction updates"} Server error

Cache:

  • Duration: 15 minutes (Cache-Control: private, max-age=900)
  • Reason: Construction data changes frequently (photos, progress updates)

Implementation Example

import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/lib/auth'
import { getConstructionProgressUseCase } from '@/lib/use-cases/construction-progress'
import { UnauthorizedError } from '@/lib/repositories/errors'

/**
 * GET /api/milestones/properties/:propertyId/construction
 *
 * Get construction progress data
 */
export async function GET(
  request: NextRequest,
  { params }: { params: Promise<{ propertyId: string }> }
) {
  try {
    // 1. Authenticate user
    const session = await auth()

    if (!session?.user?.id) {
      return NextResponse.json(
        {
          success: false,
          error: 'Unauthorized. Please sign in.',
        },
        { status: 401 }
      )
    }

    // 2. Await params
    const { propertyId } = await params

    if (!propertyId || typeof propertyId !== 'string') {
      return NextResponse.json(
        {
          success: false,
          error: 'Invalid property ID',
        },
        { status: 400 }
      )
    }

    // 3. Call use case
    const constructionData = await getConstructionProgressUseCase(
      propertyId,
      session.user.id
    )

    // 4. Return success response with 15-minute cache
    return NextResponse.json(
      {
        success: true,
        data: constructionData,
      },
      {
        status: 200,
        headers: {
          'Cache-Control': 'private, max-age=900', // Cache for 15 minutes
        },
      }
    )
  } catch (error) {
    if (error instanceof UnauthorizedError) {
      return NextResponse.json(
        {
          success: false,
          error: 'You do not have permission to view this property',
        },
        { status: 403 }
      )
    }

    return NextResponse.json(
      {
        success: false,
        error:
          error instanceof Error
            ? error.message
            : 'Failed to load construction updates',
      },
      { status: 500 }
    )
  }
}

Response:

{
  "success": true,
  "data": {
    "overallProgress": 65,
    "constructionStatus": "Ongoing",
    "currentPhase": "Roofing",
    "completedPhases": 5,
    "totalPhases": 8,
    "timeline": [
      {
        "phase": "Site Clearing",
        "status": "completed",
        "completionDate": "2024-01-15"
      },
      {
        "phase": "Foundation",
        "status": "completed",
        "completionDate": "2024-03-20"
      },
      {
        "phase": "Roofing",
        "status": "in_progress",
        "targetDate": "2024-08-30"
      }
    ],
    "recentUpdates": [
      {
        "id": "upd-1",
        "phase": "Roofing",
        "title": "Roof waterproofing completed",
        "description": "All roof waterproofing layers have been applied",
        "date": "2024-08-15",
        "photoUrl": "https://..."
      }
    ]
  }
}

Construction Phases:

  1. Site Clearing
  2. Foundation
  3. Structure
  4. Roofing
  5. Interior/Exterior Finishing
  6. Unit Completed
  7. Turnover Preparation
  8. Ready for Turnover

Features:

  • 15-minute cache (fast-changing construction data)
  • Returns overall progress percentage
  • Includes 8-phase timeline with status
  • Latest 5 construction updates with photos
  • Handles "Not Applicable" status for completed properties

Milestone Tracking Endpoint

Source: src/app/api/milestones/properties/[propertyId]/milestones/route.ts:1-162

Get complete milestone timeline with all 8 milestones and related documents.

Request Specification

GET /api/milestones/properties/{propertyId}/milestones HTTP/1.1
Host: localhost:3000
Cookie: authjs.session-token=YOUR_SESSION_TOKEN
Accept: application/json

Path Parameters:

Parameter Type Required Description
propertyId string Yes Property UUID

Headers:

Header Required Value
Cookie Yes authjs.session-token=YOUR_TOKEN
Accept No application/json

Example Requests:

# Get milestone timeline
curl "http://localhost:3000/api/milestones/properties/prop-123/milestones" \
  -H "Cookie: authjs.session-token=eyJhbGc..."

# JavaScript/TypeScript
const propertyId = 'prop-123'
const response = await fetch(`/api/milestones/properties/${propertyId}/milestones`)
const { data } = await response.json()

Response Specification

Success Response (200 OK):

{
  "success": true,
  "data": {
    "overallProgress": 75,
    "currentMilestone": {
      "id": "mile-5",
      "name": "Deed of Absolute Sale Execution",
      "status": "in_progress"
    },
    "completedCount": 6,
    "totalCount": 8,
    "milestones": [
      {
        "id": "mile-1",
        "name": "Reservation Agreement",
        "status": "completed",
        "progress": 100,
        "completionDate": "2024-01-10",
        "description": "Initial reservation agreement signed",
        "documents": [
          {
            "id": "doc-1",
            "name": "Reservation Agreement.pdf",
            "type": "RESERVATION",
            "status": "VERIFIED"
          }
        ]
      }
    ]
  }
}

Response Fields:

Field Type Description
success boolean Always true for successful requests
data.overallProgress number Overall milestone completion percentage (0-100)
data.currentMilestone object | null Current active milestone
data.currentMilestone.id string Milestone UUID
data.currentMilestone.name string Milestone name
data.currentMilestone.status string Status: "in_progress"
data.completedCount number Number of completed milestones
data.totalCount number Total number of milestones (8)
data.milestones array All 8 milestones in order
data.milestones[].id string Milestone UUID
data.milestones[].name string Milestone name
data.milestones[].status string Status: "completed", "in_progress", "pending"
data.milestones[].progress number Milestone progress percentage (0-100)
data.milestones[].completionDate string | null ISO date when completed
data.milestones[].description string Milestone description
data.milestones[].documents array Related documents
data.milestones[].documents[].id string Document UUID
data.milestones[].documents[].name string Document filename
data.milestones[].documents[].type string Document type enum
data.milestones[].documents[].status string Status: "VERIFIED", "PENDING", "REJECTED"

Milestones (in order):

  1. RESERVATION - Reservation Agreement
  2. REQUIRED_DOCS - Required Documents Submission
  3. PAYMENTS - Payment Milestones
  4. CTS - Contract to Sell Signing
  5. DOAS - Deed of Absolute Sale Execution
  6. TITLE - Title Transfer
  7. TAX_DECLARATION - Tax Declaration
  8. TURNOVER - Property Turnover

Error Responses:

Status Response Cause
400 {"success": false, "error": "Invalid property ID"} Invalid propertyId format
401 {"success": false, "error": "Unauthorized. Please sign in."} Missing/invalid session
403 {"success": false, "error": "You do not have permission..."} Property belongs to different user
500 {"success": false, "error": "Failed to load milestone data"} Server error

Cache:

  • Duration: 30 minutes (Cache-Control: private, max-age=1800)
  • Reason: Milestone status changes moderately (document submissions, approvals)

Implementation Example

import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/lib/auth'
import { getMilestoneTrackingUseCase } from '@/lib/use-cases/milestone-tracking'
import { UnauthorizedError } from '@/lib/repositories/errors'

/**
 * GET /api/milestones/properties/:propertyId/milestones
 *
 * Get milestone timeline data
 */
export async function GET(
  request: NextRequest,
  { params }: { params: Promise<{ propertyId: string }> }
) {
  try {
    // 1. Authenticate user
    const session = await auth()

    if (!session?.user?.id) {
      return NextResponse.json(
        {
          success: false,
          error: 'Unauthorized. Please sign in.',
        },
        { status: 401 }
      )
    }

    // 2. Await params
    const { propertyId } = await params

    if (!propertyId || typeof propertyId !== 'string') {
      return NextResponse.json(
        {
          success: false,
          error: 'Invalid property ID',
        },
        { status: 400 }
      )
    }

    // 3. Call use case
    const milestoneData = await getMilestoneTrackingUseCase(
      propertyId,
      session.user.id
    )

    // 4. Return success response with 30-minute cache
    return NextResponse.json(
      {
        success: true,
        data: milestoneData,
      },
      {
        status: 200,
        headers: {
          'Cache-Control': 'private, max-age=1800', // Cache for 30 minutes
        },
      }
    )
  } catch (error) {
    if (error instanceof UnauthorizedError) {
      return NextResponse.json(
        {
          success: false,
          error: 'You do not have permission to view this property',
        },
        { status: 403 }
      )
    }

    return NextResponse.json(
      {
        success: false,
        error:
          error instanceof Error
            ? error.message
            : 'Failed to load milestone data',
      },
      { status: 500 }
    )
  }
}

Response:

{
  "success": true,
  "data": {
    "overallProgress": 75,
    "currentMilestone": {
      "id": "mile-5",
      "name": "Deed of Absolute Sale Execution",
      "status": "in_progress"
    },
    "completedCount": 6,
    "totalCount": 8,
    "milestones": [
      {
        "id": "mile-1",
        "name": "Reservation Agreement",
        "status": "completed",
        "progress": 100,
        "completionDate": "2024-01-10",
        "description": "Initial reservation agreement signed",
        "documents": [
          {
            "id": "doc-1",
            "name": "Reservation Agreement.pdf",
            "type": "RESERVATION",
            "status": "VERIFIED"
          }
        ]
      }
    ]
  }
}

Milestones (in order):

  1. RESERVATION - Reservation Agreement
  2. REQUIRED_DOCS - Required Documents Submission
  3. PAYMENTS - Payment Milestones
  4. CTS - Contract to Sell Signing
  5. DOAS - Deed of Absolute Sale Execution
  6. TITLE - Title Transfer
  7. TAX_DECLARATION - Tax Declaration
  8. TURNOVER - Property Turnover

Features:

  • 30-minute cache (moderately changing data)
  • All 8 milestones with status and dates
  • Related documents per milestone
  • Overall progress percentage
  • Current milestone indicator

Source: src/app/api/milestones/properties/[propertyId]/documents/route.ts:1-165

Get 5 important documents with metadata and download URLs.

Request Specification

GET /api/milestones/properties/{propertyId}/documents HTTP/1.1
Host: localhost:3000
Cookie: authjs.session-token=YOUR_SESSION_TOKEN
Accept: application/json

Path Parameters:

Parameter Type Required Description
propertyId string Yes Property UUID

Headers:

Header Required Value
Cookie Yes authjs.session-token=YOUR_TOKEN
Accept No application/json

Example Requests:

# Get document quick links
curl "http://localhost:3000/api/milestones/properties/prop-123/documents" \
  -H "Cookie: authjs.session-token=eyJhbGc..."

# JavaScript/TypeScript
const propertyId = 'prop-123'
const response = await fetch(`/api/milestones/properties/${propertyId}/documents`)
const { data } = await response.json()

Response Specification

Success Response (200 OK):

{
  "success": true,
  "data": {
    "totalDocuments": 12,
    "quickLinks": [
      {
        "id": "doc-1",
        "name": "Reservation Agreement",
        "icon": "FileText",
        "date": "2024-01-10",
        "fileSize": 245678,
        "formattedSize": "240 KB",
        "fileUrl": "https://storage.../FRA.pdf",
        "isAvailable": true
      },
      {
        "id": null,
        "name": "Contract to Sell",
        "icon": "FileText",
        "message": "Not yet available",
        "isAvailable": false
      }
    ],
    "documentCounts": {
      "total": 12,
      "byStatus": {
        "verified": 8,
        "pending": 3,
        "rejected": 1
      },
      "byType": {
        "RESERVATION": 1,
        "CTS": 1,
        "PAYMENT": 8
      }
    }
  }
}

Response Fields:

Field Type Description
success boolean Always true for successful requests
data.totalDocuments number Total number of documents for this property
data.quickLinks array 5 important documents (in priority order)
data.quickLinks[].id string | null Document UUID (null if not available)
data.quickLinks[].name string Document display name
data.quickLinks[].icon string Icon identifier for UI
data.quickLinks[].date string | null ISO date when document was added
data.quickLinks[].fileSize number | null File size in bytes
data.quickLinks[].formattedSize string | null Human-readable file size (e.g., "240 KB")
data.quickLinks[].fileUrl string | null Download URL for the document
data.quickLinks[].message string | null Status message (e.g., "Not yet available")
data.quickLinks[].isAvailable boolean Whether document is available for download
data.documentCounts object Document statistics
data.documentCounts.total number Total documents
data.documentCounts.byStatus object Count by status (verified, pending, rejected)
data.documentCounts.byType object Count by document type

Document Types (in priority order):

  1. Reservation Agreement (FRA)
  2. Contract to Sell (CTS)
  3. Deed of Absolute Sale (DOAS)
  4. Official Receipts (OR)
  5. Statement of Account (SOA)

Error Responses:

Status Response Cause
400 {"success": false, "error": "Invalid property ID"} Invalid propertyId format
401 {"success": false, "error": "Unauthorized. Please sign in."} Missing/invalid session
403 {"success": false, "error": "You do not have permission..."} Property belongs to different user
500 {"success": false, "error": "Failed to load document quick links"} Server error

Cache:

  • Duration: 1 hour (Cache-Control: private, max-age=3600)
  • Reason: Document metadata rarely changes (uploads are infrequent)

Implementation Example

import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/lib/auth'
import { getDocumentQuickLinksUseCase } from '@/lib/use-cases/document-quick-links'
import { UnauthorizedError } from '@/lib/repositories/errors'

/**
 * GET /api/milestones/properties/:propertyId/documents
 *
 * Get document quick links
 */
export async function GET(
  request: NextRequest,
  { params }: { params: Promise<{ propertyId: string }> }
) {
  try {
    // 1. Authenticate user
    const session = await auth()

    if (!session?.user?.id) {
      return NextResponse.json(
        {
          success: false,
          error: 'Unauthorized. Please sign in.',
        },
        { status: 401 }
      )
    }

    // 2. Await params
    const { propertyId } = await params

    if (!propertyId || typeof propertyId !== 'string') {
      return NextResponse.json(
        {
          success: false,
          error: 'Invalid property ID',
        },
        { status: 400 }
      )
    }

    // 3. Call use case
    const documentData = await getDocumentQuickLinksUseCase(
      propertyId,
      session.user.id
    )

    // 4. Return success response with 1-hour cache
    return NextResponse.json(
      {
        success: true,
        data: documentData,
      },
      {
        status: 200,
        headers: {
          'Cache-Control': 'private, max-age=3600', // Cache for 1 hour
        },
      }
    )
  } catch (error) {
    if (error instanceof UnauthorizedError) {
      return NextResponse.json(
        {
          success: false,
          error: 'You do not have permission to view this property',
        },
        { status: 403 }
      )
    }

    return NextResponse.json(
      {
        success: false,
        error:
          error instanceof Error
            ? error.message
            : 'Failed to load document quick links',
      },
      { status: 500 }
    )
  }
}

Response:

{
  "success": true,
  "data": {
    "totalDocuments": 12,
    "quickLinks": [
      {
        "id": "doc-1",
        "name": "Reservation Agreement",
        "icon": "FileText",
        "date": "2024-01-10",
        "fileSize": 245678,
        "formattedSize": "240 KB",
        "fileUrl": "https://storage.../FRA.pdf",
        "isAvailable": true
      },
      {
        "id": null,
        "name": "Contract to Sell",
        "icon": "FileText",
        "message": "Not yet available",
        "isAvailable": false
      }
    ],
    "documentCounts": {
      "total": 12,
      "byStatus": {
        "verified": 8,
        "pending": 3,
        "rejected": 1
      },
      "byType": {
        "RESERVATION": 1,
        "CTS": 1,
        "PAYMENT": 8
      }
    }
  }
}

Documents (in order):

  1. Reservation Agreement (FRA)
  2. Contract to Sell (CTS)
  3. Deed of Absolute Sale (DOAS)
  4. Official Receipts (OR)
  5. Statement of Account (SOA)

Features:

  • Shows "Not yet available" for missing documents
  • Provides download URLs (not preview)
  • Formats file sizes (KB, MB)
  • Document counts by status and type
  • 1-hour cache for document metadata

Property Information Endpoint

Source: src/app/api/milestones/properties/[propertyId]/info/route.ts:1-159

Get property specifications including unit details, features, and developer information.

Request Specification

GET /api/milestones/properties/{propertyId}/info HTTP/1.1
Host: localhost:3000
Cookie: authjs.session-token=YOUR_SESSION_TOKEN
Accept: application/json

Path Parameters:

Parameter Type Required Description
propertyId string Yes Property UUID

Headers:

Header Required Value
Cookie Yes authjs.session-token=YOUR_TOKEN
Accept No application/json

Example Requests:

# Get property information
curl "http://localhost:3000/api/milestones/properties/prop-123/info" \
  -H "Cookie: authjs.session-token=eyJhbGc..."

# JavaScript/TypeScript
const propertyId = 'prop-123'
const response = await fetch(`/api/milestones/properties/${propertyId}/info`)
const { data } = await response.json()

Response Specification

Success Response (200 OK):

{
  "success": true,
  "data": {
    "unitNumber": "12A",
    "floorLevel": "12th Floor",
    "unitType": "2BR",
    "floorArea": "45.5 sqm",
    "balconyArea": "5.2 sqm",
    "parkingSlots": 1,
    "developerName": "Ayala Land Inc.",
    "projectName": "Alveo High Park"
  }
}

Response Fields:

Field Type Description
success boolean Always true for successful requests
data.unitNumber string Unit number
data.floorLevel string Floor level with ordinal (e.g., "12th Floor")
data.unitType string Unit type (e.g., "2BR", "Studio")
data.floorArea string Floor area with unit (e.g., "45.5 sqm")
data.balconyArea string | null Balcony area with unit or "N/A"
data.parkingSlots number Number of parking slots
data.developerName string Developer company name
data.projectName string Project name

Error Responses:

Status Response Cause
400 {"success": false, "error": "Invalid property ID"} Invalid propertyId format
401 {"success": false, "error": "Unauthorized. Please sign in."} Missing/invalid session
403 {"success": false, "error": "You do not have permission..."} Property belongs to different user
404 {"success": false, "error": "Property not found"} Property doesn't exist
500 {"success": false, "error": "Failed to load property information"} Server error

Cache:

  • Duration: 1 hour (Cache-Control: private, max-age=3600)
  • Reason: Property specifications are static

Implementation Example

import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/lib/auth'
import { getPropertyInformationUseCase } from '@/lib/use-cases/property-details'
import { UnauthorizedError, NotFoundError } from '@/lib/repositories/errors'

/**
 * GET /api/milestones/properties/:propertyId/info
 *
 * Get property specifications
 */
export async function GET(
  request: NextRequest,
  { params }: { params: Promise<{ propertyId: string }> }
) {
  try {
    // 1. Authenticate user
    const session = await auth()

    if (!session?.user?.id) {
      return NextResponse.json(
        {
          success: false,
          error: 'Unauthorized. Please sign in.',
        },
        { status: 401 }
      )
    }

    // 2. Await params
    const { propertyId } = await params

    if (!propertyId || typeof propertyId !== 'string') {
      return NextResponse.json(
        {
          success: false,
          error: 'Invalid property ID',
        },
        { status: 400 }
      )
    }

    // 3. Call use case
    const propertyInfo = await getPropertyInformationUseCase(
      propertyId,
      session.user.id
    )

    // 4. Return success response
    return NextResponse.json(
      {
        success: true,
        data: propertyInfo,
      },
      {
        status: 200,
        headers: {
          'Cache-Control': 'private, max-age=3600', // Cache for 1 hour
        },
      }
    )
  } catch (error) {
    if (error instanceof UnauthorizedError) {
      return NextResponse.json(
        {
          success: false,
          error: 'You do not have permission to view this property',
        },
        { status: 403 }
      )
    }

    if (error instanceof NotFoundError) {
      return NextResponse.json(
        {
          success: false,
          error: 'Property not found',
        },
        { status: 404 }
      )
    }

    return NextResponse.json(
      {
        success: false,
        error:
          error instanceof Error
            ? error.message
            : 'Failed to load property information',
      },
      { status: 500 }
    )
  }
}

Response:

{
  "success": true,
  "data": {
    "unitNumber": "12A",
    "floorLevel": "12th Floor",
    "unitType": "2BR",
    "floorArea": "45.5 sqm",
    "balconyArea": "5.2 sqm",
    "parkingSlots": 1,
    "developerName": "Ayala Land Inc.",
    "projectName": "Alveo High Park"
  }
}

Features:

  • Unit specifications (number, floor, type, areas)
  • Property features (parking slots)
  • Developer information
  • Handles missing/optional information (shows "N/A")
  • 1-hour cache for static property data
  • Custom error handling for 403/404/500

Common Patterns in Property Endpoints

Consistent Response Format

All property endpoints use consistent success/error response structure:

// Success response
{
  "success": true,
  "data": { /* endpoint-specific data */ }
}

// Error response
{
  "success": false,
  "error": "Error message"
}

Authentication Pattern

const session = await auth()

if (!session?.user?.id) {
  return NextResponse.json(
    { success: false, error: 'Unauthorized. Please sign in.' },
    { status: 401 }
  )
}

Next.js 15 Async Params

All dynamic routes use await params pattern:

export async function GET(
  request: NextRequest,
  { params }: { params: Promise<{ propertyId: string }> }
) {
  const { propertyId } = await params // Required in Next.js 15
  // ...
}

Cache Control Headers

Different cache durations based on data volatility:

Endpoint Cache Duration Reason
Property List 1 hour Static property metadata
Property Details 1 hour Static specifications
Construction Updates 15 minutes Frequently changing progress
Milestone Data 30 minutes Moderately changing status
Document Quick Links 1 hour Document metadata rarely changes
Property Information 1 hour Static unit specifications

CORS Support

All endpoints include OPTIONS handler for cross-origin requests:

export async function OPTIONS(_request: NextRequest) {
  return NextResponse.json(
    {},
    {
      status: 200,
      headers: {
        'Access-Control-Allow-Methods': 'GET, OPTIONS',
        'Access-Control-Allow-Headers': 'Content-Type, Authorization',
      },
    }
  )
}

API Keys

// app/api/external/route.ts
import { NextResponse } from 'next/server'

export async function POST(request: Request) {
  // Check API key
  const apiKey = request.headers.get('x-api-key')

  if (apiKey !== process.env.API_KEY) {
    return NextResponse.json(
      { error: 'Invalid API key' },
      { status: 401 }
    )
  }

  // Process request
  const data = await request.json()
  const result = await processData(data)

  return NextResponse.json({ success: true, result })
}

Request Handling

Query Parameters

// GET /api/search?q=alveo&limit=10
export async function GET(request: Request) {
  const { searchParams } = new URL(request.url)

  const query = searchParams.get('q')
  const limit = parseInt(searchParams.get('limit') || '10')

  const results = await search(query, limit)

  return NextResponse.json({ results })
}

Route Parameters

// app/api/properties/[id]/route.ts
import { NextResponse } from 'next/server'

export async function GET(
  request: Request,
  { params }: { params: Promise<{ id: string }> }
) {
  const { id } = await params

  const property = await getPropertyById(id)

  if (!property) {
    return NextResponse.json(
      { error: 'Property not found' },
      { status: 404 }
    )
  }

  return NextResponse.json({ property })
}

JSON Body

export async function POST(request: Request) {
  try {
    const body = await request.json()

    // Validate with Zod
    const validated = schema.parse(body)

    // Process
    const result = await processData(validated)

    return NextResponse.json({ success: true, result })

  } catch (error) {
    if (error instanceof z.ZodError) {
      return NextResponse.json(
        { error: 'Validation failed', details: error.errors },
        { status: 400 }
      )
    }

    return NextResponse.json(
      { error: 'Internal server error' },
      { status: 500 }
    )
  }
}

Form Data

export async function POST(request: Request) {
  const formData = await request.formData()

  const name = formData.get('name') as string
  const email = formData.get('email') as string
  const file = formData.get('file') as File

  // Process form data
  const result = await processForm({ name, email, file })

  return NextResponse.json({ success: true, result })
}

File Uploads

Single File Upload

// app/api/upload/route.ts
import { NextResponse } from 'next/server'
import { writeFile } from 'fs/promises'
import { join } from 'path'

export async function POST(request: Request) {
  try {
    const formData = await request.formData()
    const file = formData.get('file') as File

    if (!file) {
      return NextResponse.json(
        { error: 'No file provided' },
        { status: 400 }
      )
    }

    // Validate file type
    const allowedTypes = ['image/jpeg', 'image/png', 'application/pdf']
    if (!allowedTypes.includes(file.type)) {
      return NextResponse.json(
        { error: 'Invalid file type' },
        { status: 400 }
      )
    }

    // Validate file size (10MB max)
    if (file.size > 10 * 1024 * 1024) {
      return NextResponse.json(
        { error: 'File too large' },
        { status: 400 }
      )
    }

    // Save file
    const bytes = await file.arrayBuffer()
    const buffer = Buffer.from(bytes)

    const filename = `${Date.now()}-${file.name}`
    const path = join(process.cwd(), 'uploads', filename)

    await writeFile(path, buffer)

    return NextResponse.json({
      success: true,
      filename,
      size: file.size,
      type: file.type,
    })

  } catch (error) {
    return NextResponse.json(
      { error: 'Upload failed' },
      { status: 500 }
    )
  }
}

Multiple File Upload

export async function POST(request: Request) {
  const formData = await request.formData()
  const files = formData.getAll('files') as File[]

  if (files.length === 0) {
    return NextResponse.json(
      { error: 'No files provided' },
      { status: 400 }
    )
  }

  const uploadedFiles = []

  for (const file of files) {
    // Process each file
    const result = await saveFile(file)
    uploadedFiles.push(result)
  }

  return NextResponse.json({
    success: true,
    files: uploadedFiles,
  })
}

Webhooks

Payment Webhook

// app/api/webhooks/payment/route.ts
import { NextResponse } from 'next/server'
import { createLogger } from '@/lib/logger'
import crypto from 'crypto'

const logger = createLogger('PaymentWebhook')

export async function POST(request: Request) {
  try {
    // 1. Verify webhook signature
    const signature = request.headers.get('x-signature')
    const body = await request.text()

    if (!verifySignature(body, signature)) {
      logger.warn('Invalid webhook signature')
      return NextResponse.json(
        { error: 'Invalid signature' },
        { status: 401 }
      )
    }

    // 2. Parse payload
    const payload = JSON.parse(body)

    // 3. Process event
    switch (payload.type) {
      case 'payment.succeeded':
        await handlePaymentSuccess(payload.data)
        break

      case 'payment.failed':
        await handlePaymentFailure(payload.data)
        break

      default:
        logger.warn(`Unknown event type: ${payload.type}`)
    }

    // 4. Return 200 to acknowledge receipt
    return NextResponse.json({ received: true })

  } catch (error) {
    logger.error('Webhook processing failed', error)
    return NextResponse.json(
      { error: 'Webhook processing failed' },
      { status: 500 }
    )
  }
}

function verifySignature(payload: string, signature: string | null): boolean {
  if (!signature) return false

  const secret = process.env.WEBHOOK_SECRET
  if (!secret) return false

  const hmac = crypto.createHmac('sha256', secret)
  const expectedSignature = hmac.update(payload).digest('hex')

  return signature === expectedSignature
}

async function handlePaymentSuccess(data: any) {
  logger.info('Payment succeeded', { paymentId: data.id })

  // Update payment record
  await updatePaymentStatus(data.id, 'COMPLETED')

  // Send confirmation email
  await sendPaymentConfirmation(data.customerId, data)

  // Revalidate relevant pages
  revalidatePath(`/payments`)
}

async function handlePaymentFailure(data: any) {
  logger.error('Payment failed', { paymentId: data.id })

  // Update payment record
  await updatePaymentStatus(data.id, 'FAILED')

  // Notify customer
  await sendPaymentFailureNotification(data.customerId, data)
}

Error Handling

Centralized Error Handler

import { handleApiError } from '@/lib/errors/api-error-handler'

export async function GET(request: Request) {
  try {
    // Route logic
    const data = await fetchData()
    return NextResponse.json({ success: true, data })

  } catch (error) {
    // Automatically maps errors to proper HTTP responses
    return handleApiError(error)
  }
}

Custom Error Responses

export async function POST(request: Request) {
  try {
    const data = await request.json()

    // Validation error
    if (!data.email) {
      return NextResponse.json(
        { error: 'Email is required', field: 'email' },
        { status: 400 }
      )
    }

    // Not found error
    const user = await findUser(data.email)
    if (!user) {
      return NextResponse.json(
        { error: 'User not found' },
        { status: 404 }
      )
    }

    // Success
    return NextResponse.json({ success: true, user })

  } catch (error) {
    return NextResponse.json(
      { error: 'Internal server error' },
      { status: 500 }
    )
  }
}

Response Helpers

JSON Response

// Success
NextResponse.json({ success: true, data })

// Error
NextResponse.json({ error: 'Message' }, { status: 400 })

// With headers
NextResponse.json(data, {
  headers: {
    'Cache-Control': 'no-store',
    'X-Custom-Header': 'value',
  },
})

Redirect

import { NextResponse } from 'next/server'

export async function GET(request: Request) {
  // Temporary redirect (302)
  return NextResponse.redirect('https://example.com')

  // Permanent redirect (301)
  return NextResponse.redirect('https://example.com', { status: 301 })
}

Streaming Response

export async function GET(request: Request) {
  const encoder = new TextEncoder()

  const stream = new ReadableStream({
    async start(controller) {
      for (let i = 0; i < 10; i++) {
        controller.enqueue(encoder.encode(`chunk ${i}\n`))
        await new Promise((resolve) => setTimeout(resolve, 1000))
      }
      controller.close()
    },
  })

  return new Response(stream, {
    headers: {
      'Content-Type': 'text/plain',
      'Transfer-Encoding': 'chunked',
    },
  })
}

Best Practices

Do

  • Always validate input
  • Check authentication for protected routes
  • Use handleApiError for consistent errors
  • Log errors for debugging
  • Return proper HTTP status codes
  • Verify webhook signatures
  • Set appropriate cache headers
  • Use TypeScript for type safety

Don't

  • Expose sensitive data in responses
  • Skip authentication checks
  • Return detailed error messages to clients
  • Use API routes for internal data fetching
  • Forget to validate file uploads
  • Ignore webhook signature verification
  • Skip logging for webhooks

Testing API Routes

import { GET, POST } from '../route'
import { auth } from '@/lib/auth'

jest.mock('@/lib/auth')

describe('GET /api/dashboard', () => {
  it('should return 401 when not authenticated', async () => {
    ;(auth as jest.Mock).mockResolvedValue(null)

    const request = new Request('http://localhost/api/dashboard')
    const response = await GET(request)

    expect(response.status).toBe(401)
  })

  it('should return dashboard data when authenticated', async () => {
    ;(auth as jest.Mock).mockResolvedValue({
      user: { id: 'user-123' },
    })

    const request = new Request('http://localhost/api/dashboard')
    const response = await GET(request)

    expect(response.status).toBe(200)
    const json = await response.json()
    expect(json.success).toBe(true)
  })
})


Next Steps

  1. Review Server Actions for UI mutations
  2. Learn about Authentication patterns
  3. Understand Error Handling
  4. Explore Repositories layer