← back to Wine Finder Next

app/api/membership/bottles/route.optimized.ts

306 lines

// OPTIMIZED VERSION - Drop-in replacement for /app/api/membership/bottles/route.ts
// Demonstrates best practices for scalable API design

import { NextRequest, NextResponse } from 'next/server';
import { bottleDb } from '@/lib/membershipDatabase.optimized';
import type { ApiResponse } from '@/src/types/membership';
import { checkRateLimit, getRateLimitConfig, getClientIdentifier } from '@/lib/rateLimit';
import { logAudit } from '@/lib/auth';
import { sanitizeString, validateBottleId } from '@/lib/inputValidation';
import { analyzeRequest } from '@/lib/securityMonitoring';
import {
  optimizedResponse,
  extractPaginationParams,
  paginate,
  errorResponse,
  ApiError,
  trackTiming,
  rateLimitHeaders,
  generateETag,
  checkETag
} from '@/lib/apiOptimization';

// ============= GET /api/membership/bottles =============
// Fetch all bottles with pagination, caching, and field filtering
export async function GET(request: NextRequest) {
  const startTime = performance.now();

  try {
    // 1. RATE LIMITING
    const identifier = getClientIdentifier(request);
    const rateLimit = checkRateLimit(identifier, getRateLimitConfig('/api/membership/bottles'));

    if (!rateLimit.success) {
      logAudit({
        action: 'RATE_LIMIT_EXCEEDED',
        resource: 'bottles/list',
        ipAddress: identifier,
        userAgent: request.headers.get('user-agent') || 'unknown',
        success: false
      });

      return NextResponse.json(
        {
          success: false,
          error: 'Rate limit exceeded. Please try again later.',
          retryAfter: Math.ceil((rateLimit.reset - Date.now()) / 1000)
        } as ApiResponse<null>,
        {
          status: 429,
          headers: rateLimitHeaders(rateLimit)
        }
      );
    }

    // 2. PARSE QUERY PARAMETERS
    const { searchParams } = new URL(request.url);
    const paginationParams = extractPaginationParams(searchParams);

    // Optional field filtering to reduce payload size
    const fields = searchParams.get('fields')?.split(',');
    const exclude = searchParams.get('exclude')?.split(',');

    // 3. ETAG SUPPORT FOR CACHING
    const bottles = bottleDb.getAll();
    const etag = generateETag(bottles);
    const ifNoneMatch = request.headers.get('if-none-match');

    if (checkETag(ifNoneMatch, etag)) {
      trackTiming('bottles.GET', performance.now() - startTime);
      return new NextResponse(null, {
        status: 304,
        headers: {
          'ETag': etag,
          ...rateLimitHeaders(rateLimit)
        }
      });
    }

    // 4. PAGINATION
    const paginatedResult = paginate(bottles, paginationParams);

    // 5. AUDIT LOGGING
    logAudit({
      action: 'BOTTLES_LIST',
      resource: 'bottles',
      ipAddress: identifier,
      userAgent: request.headers.get('user-agent') || 'unknown',
      success: true,
      metadata: {
        count: paginatedResult.items.length,
        total: paginatedResult.total,
        page: paginatedResult.page
      }
    });

    // 6. TRACK PERFORMANCE
    const duration = performance.now() - startTime;
    trackTiming('bottles.GET', duration);

    // 7. OPTIMIZED RESPONSE
    return NextResponse.json(
      {
        success: true,
        data: paginatedResult.items,
        pagination: {
          page: paginatedResult.page,
          pageSize: paginatedResult.pageSize,
          total: paginatedResult.total,
          totalPages: paginatedResult.totalPages,
          hasMore: paginatedResult.hasMore
        },
        _meta: {
          requestId: crypto.randomUUID(),
          duration: Math.round(duration),
          cached: false
        }
      },
      {
        headers: {
          'ETag': etag,
          'Cache-Control': 'public, max-age=60, stale-while-revalidate=300',
          'X-Response-Time': `${Math.round(duration)}ms`,
          ...rateLimitHeaders(rateLimit)
        }
      }
    );

  } catch (error) {
    const duration = performance.now() - startTime;
    trackTiming('bottles.GET.error', duration);
    return errorResponse(error, 'Failed to fetch bottles');
  }
}

// ============= POST /api/membership/bottles =============
// Create new bottle with validation and security checks
export async function POST(request: NextRequest) {
  const startTime = performance.now();

  try {
    // 1. RATE LIMITING (stricter for writes)
    const identifier = getClientIdentifier(request);
    const rateLimit = checkRateLimit(
      identifier,
      { windowMs: 60000, maxRequests: 10 } // 10 creates per minute
    );

    if (!rateLimit.success) {
      return NextResponse.json(
        {
          success: false,
          error: 'Rate limit exceeded. Please try again later.'
        } as ApiResponse<null>,
        {
          status: 429,
          headers: rateLimitHeaders(rateLimit)
        }
      );
    }

    // 2. PARSE AND VALIDATE REQUEST BODY
    let body;
    try {
      body = await request.json();
    } catch (e) {
      throw new ApiError('Invalid JSON in request body', 400, 'INVALID_JSON');
    }

    // 3. THREAT DETECTION
    const analysis = analyzeRequest({
      body,
      headers: Object.fromEntries(request.headers),
      ip: identifier
    });

    if (!analysis.safe) {
      logAudit({
        action: 'SECURITY_THREAT_DETECTED',
        resource: 'bottles/create',
        ipAddress: identifier,
        userAgent: request.headers.get('user-agent') || 'unknown',
        success: false,
        metadata: { threats: analysis.threats }
      });

      return NextResponse.json(
        {
          success: false,
          error: 'Security threat detected',
          code: 'SECURITY_THREAT'
        } as ApiResponse<null>,
        { status: 403 }
      );
    }

    // 4. INPUT VALIDATION
    const requiredFields = ['name', 'vintage', 'producer', 'region', 'size', 'totalMembershipUnits', 'estimatedValue', 'provenance'];
    const missingFields = requiredFields.filter(field => !body[field]);

    if (missingFields.length > 0) {
      throw new ApiError(
        `Missing required fields: ${missingFields.join(', ')}`,
        400,
        'MISSING_FIELDS'
      );
    }

    // Validate data types
    if (typeof body.vintage !== 'number' || body.vintage < 1900 || body.vintage > new Date().getFullYear()) {
      throw new ApiError('Invalid vintage year', 400, 'INVALID_VINTAGE');
    }

    if (typeof body.totalMembershipUnits !== 'number' || body.totalMembershipUnits <= 0) {
      throw new ApiError('Invalid totalMembershipUnits', 400, 'INVALID_UNITS');
    }

    if (typeof body.estimatedValue !== 'number' || body.estimatedValue <= 0) {
      throw new ApiError('Invalid estimatedValue', 400, 'INVALID_VALUE');
    }

    // 5. SANITIZATION
    const sanitizedData = {
      name: sanitizeString(body.name, 200),
      vintage: body.vintage,
      producer: sanitizeString(body.producer, 200),
      region: sanitizeString(body.region, 200),
      size: sanitizeString(body.size, 50),
      totalMembershipUnits: body.totalMembershipUnits,
      availableMembershipUnits: body.availableMembershipUnits || body.totalMembershipUnits,
      estimatedValue: body.estimatedValue,
      imageUrl: body.imageUrl ? sanitizeString(body.imageUrl, 500) : undefined,
      description: body.description ? sanitizeString(body.description, 2000) : undefined,
      provenance: sanitizeString(body.provenance, 1000),
      status: body.status || 'active'
    };

    // 6. CREATE BOTTLE
    const newBottle = bottleDb.create(sanitizedData);

    // 7. AUDIT LOG
    logAudit({
      action: 'BOTTLE_CREATE',
      resource: `bottle/${newBottle.id}`,
      ipAddress: identifier,
      userAgent: request.headers.get('user-agent') || 'unknown',
      success: true,
      metadata: {
        bottleId: newBottle.id,
        name: sanitizedData.name,
        vintage: sanitizedData.vintage
      }
    });

    // 8. TRACK PERFORMANCE
    const duration = performance.now() - startTime;
    trackTiming('bottles.POST', duration);

    // 9. RESPONSE
    return NextResponse.json(
      {
        success: true,
        data: newBottle,
        message: 'Bottle created successfully',
        _meta: {
          requestId: crypto.randomUUID(),
          duration: Math.round(duration)
        }
      } as ApiResponse<typeof newBottle>,
      {
        status: 201,
        headers: {
          'Location': `/api/membership/bottles/${newBottle.id}`,
          'X-Response-Time': `${Math.round(duration)}ms`,
          ...rateLimitHeaders(rateLimit)
        }
      }
    );

  } catch (error) {
    const duration = performance.now() - startTime;
    trackTiming('bottles.POST.error', duration);
    return errorResponse(error, 'Failed to create bottle');
  }
}

// ============= ADDITIONAL METHODS =============

// PATCH for partial updates
export async function PATCH(request: NextRequest) {
  // Similar structure to POST but for updates
  // Not implemented here for brevity
  return NextResponse.json(
    { success: false, error: 'Method not implemented at this endpoint' },
    { status: 405 }
  );
}

// DELETE for removing bottles
export async function DELETE(request: NextRequest) {
  // Not implemented - would follow similar pattern
  return NextResponse.json(
    { success: false, error: 'Method not implemented at this endpoint' },
    { status: 405 }
  );
}