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¶
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¶
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)¶
Causes:
- Attempting to access another user's property
- Valid session but wrong ownership
Resolution: Verify property ID belongs to authenticated user.
400 Bad Request¶
Causes:
- Missing required parameters
- Invalid parameter format
- Parameter validation failed
Resolution: Check request parameters match API specification.
404 Not Found¶
Causes:
- Resource ID doesn't exist
- Resource was deleted
Resolution: Verify the resource ID is correct.
500 Internal Server Error¶
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
queryandqparameters
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):
- Site Clearing
- Foundation
- Structure
- Roofing
- Interior/Exterior Finishing
- Unit Completed
- Turnover Preparation
- 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:
- Site Clearing
- Foundation
- Structure
- Roofing
- Interior/Exterior Finishing
- Unit Completed
- Turnover Preparation
- 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):
- RESERVATION - Reservation Agreement
- REQUIRED_DOCS - Required Documents Submission
- PAYMENTS - Payment Milestones
- CTS - Contract to Sell Signing
- DOAS - Deed of Absolute Sale Execution
- TITLE - Title Transfer
- TAX_DECLARATION - Tax Declaration
- 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):
- RESERVATION - Reservation Agreement
- REQUIRED_DOCS - Required Documents Submission
- PAYMENTS - Payment Milestones
- CTS - Contract to Sell Signing
- DOAS - Deed of Absolute Sale Execution
- TITLE - Title Transfer
- TAX_DECLARATION - Tax Declaration
- 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
Document Quick Links Endpoint¶
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):
- Reservation Agreement (FRA)
- Contract to Sell (CTS)
- Deed of Absolute Sale (DOAS)
- Official Receipts (OR)
- 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):
- Reservation Agreement (FRA)
- Contract to Sell (CTS)
- Deed of Absolute Sale (DOAS)
- Official Receipts (OR)
- 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)
})
})
Related Documentation¶
- Server Actions - Data mutations
- Authentication - NextAuth.js
- Error Handling - Error patterns
- Testing - Testing strategies
Next Steps¶
- Review Server Actions for UI mutations
- Learn about Authentication patterns
- Understand Error Handling
- Explore Repositories layer