The function `checkRateLimit` in `src/lib/rate-limit.ts` has been changed from synchronous to async (returns a Promise). I need you to find ALL callers and check if they already await it or need updating.

Search for `checkRateLimit(` in all `src/**/*.ts` files (excluding `src/lib/rate-limit.ts` itself) and for each file, read the relevant lines and update them to add `await` if missing. Also ensure the containing function is `async` (they should already be since they're route handlers).

Also, `getRateLimitHeaders` signature changed — it now takes a result object directly instead of (identifier, maxRequests, windowMs). Check if any callers use the old signature and update them.

Here are the files that import from rate-limit:
- src/app/api/messages/route.ts
- src/app/api/messages/[id]/route.ts
- src/app/api/messages/[id]/send/route.ts
- src/app/api/messages/start/route.ts
- src/app/api/abn-lookup/route.ts
- src/app/api/match/find-matches/route.ts
- src/app/api/company-logo/route.ts
- src/app/api/ai/generate-job/route.ts
- src/app/api/ai/voice-complete/route.ts
- src/app/api/ai/generate-profile/route.ts
- src/app/api/ai/parse-profile-voice/route.ts
- src/app/api/ai/transcribe-voice/route.ts
- src/app/api/payments/create/route.ts
- src/app/api/auth/verify-email/route.ts

For each file:
1. Read the file
2. Check if the call `checkRateLimit(...)` is already awaited. If not, add `await` before it. The pattern will look like `const rateLimit = checkRateLimit(...)` — change to `const rateLimit = await checkRateLimit(...)`
3. Check if `getRateLimitHeaders` is called with the old signature `(identifier, maxRequests, windowMs)` and update to the new signature using the result object.
4. Make the edits

IMPORTANT: Actually make the edits using the Edit tool. Don't just report what needs to change.


I'll start by reading the rate-limit source file to understand the new signatures, then read all the caller files in parallel.


     1→/**
     2→ * Rate limiting for API endpoints
     3→ *
     4→ * Production: Uses Upstash Redis for distributed rate limiting across
     5→ * serverless invocations. Falls back to in-memory Map() if Redis is
     6→ * unavailable (local dev or connection failure).
     7→ *
     8→ * Env vars (pick one):
     9→ *   UPSTASH_REDIS_REST_URL + UPSTASH_REDIS_REST_TOKEN  (preferred)
    10→ *   LIMITER_STORAGE_URI  (legacy redis:// URL — auto-converted)
    11→ */
    12→
    13→import { Ratelimit } from "@upstash/ratelimit";
    14→import { Redis } from "@upstash/redis";
    15→
    16→// ---------------------------------------------------------------------------
    17→// Constants
    18→// ---------------------------------------------------------------------------
    19→const WINDOW_MS = 15 * 60 * 1000; // 15 minutes
    20→const MAX_REQUESTS = 100;
    21→
    22→// ---------------------------------------------------------------------------
    23→// In-memory fallback (local dev / Redis failure)
    24→// ---------------------------------------------------------------------------
    25→
    26→interface RateLimitEntry {
    27→  count: number;
    28→  resetTime: number;
    29→}
    30→
    31→const rateLimitStore = new Map<string, RateLimitEntry>();
    32→
    33→function memoryCheckRateLimit(
    34→  identifier: string,
    35→  maxRequests: number,
    36→  windowMs: number
    37→): { allowed: boolean; remaining: number; resetTime: number; totalLimit: number } {
    38→  const now = Date.now();
    39→  const entry = rateLimitStore.get(identifier);
    40→
    41→  if (!entry || now > entry.resetTime) {
    42→    rateLimitStore.set(identifier, { count: 1, resetTime: now + windowMs });
    43→    return { allowed: true, remaining: maxRequests - 1, resetTime: now + windowMs, totalLimit: maxRequests };
    44→  }
    45→
    46→  if (entry.count >= maxRequests) {
    47→    return { allowed: false, remaining: 0, resetTime: entry.resetTime, totalLimit: maxRequests };
    48→  }
    49→
    50→  entry.count++;
    51→  return { allowed: true, remaining: maxRequests - entry.count, resetTime: entry.resetTime, totalLimit: maxRequests };
    52→}
    53→
    54→// Cleanup interval to prevent memory leaks in long-running processes
    55→const CLEANUP_INTERVAL_MS = 5 * 60 * 1000;
    56→let cleanupInterval: NodeJS.Timeout | null = null;
    57→
    58→export function startRateLimitCleanup(): void {
    59→  if (cleanupInterval) return;
    60→  cleanupInterval = setInterval(() => {
    61→    const now = Date.now();
    62→    for (const [key, entry] of rateLimitStore.entries()) {
    63→      if (now > entry.resetTime) rateLimitStore.delete(key);
    64→    }
    65→  }, CLEANUP_INTERVAL_MS);
    66→  if (cleanupInterval.unref) cleanupInterval.unref();
    67→}
    68→
    69→export function stopRateLimitCleanup(): void {
    70→  if (cleanupInterval) {
    71→    clearInterval(cleanupInterval);
    72→    cleanupInterval = null;
    73→  }
    74→}
    75→
    76→// ---------------------------------------------------------------------------
    77→// Redis client (singleton, lazy-init)
    78→// ---------------------------------------------------------------------------
    79→
    80→let redis: Redis | null = null;
    81→let redisAvailable = true; // flipped to false on connection failure
    82→
    83→function getRedis(): Redis | null {
    84→  if (!redisAvailable) return null;
    85→  if (redis) return redis;
    86→
    87→  const restUrl = process.env.UPSTASH_REDIS_REST_URL;
    88→  const restToken = process.env.UPSTASH_REDIS_REST_TOKEN;
    89→
    90→  if (restUrl && restToken) {
    91→    redis = new Redis({ url: restUrl, token: restToken });
    92→    return redis;
    93→  }
    94→
    95→  // Parse legacy redis:// URI into Upstash REST format
    96→  // Expected: redis://default:<token>@<host>.upstash.io:6379
    97→  const legacyUri = process.env.LIMITER_STORAGE_URI;
    98→  if (legacyUri) {
    99→    try {
   100→      const parsed = new URL(legacyUri);
   101→      const host = parsed.hostname;
   102→      const token = decodeURIComponent(parsed.password);
   103→      if (host && token) {
   104→        redis = new Redis({ url: `https://${host}`, token });
   105→        return redis;
   106→      }
   107→    } catch {
   108→      // Parsing failed — fall through to memory
   109→    }
   110→  }
   111→
   112→  // No Redis config — use memory
   113→  redisAvailable = false;
   114→  return null;
   115→}
   116→
   117→// ---------------------------------------------------------------------------
   118→// Upstash Ratelimit instances (cached per unique window config)
   119→// ---------------------------------------------------------------------------
   120→
   121→const limiterCache = new Map<string, Ratelimit>();
   122→
   123→function getLimiter(maxRequests: number, windowMs: number): Ratelimit | null {
   124→  const client = getRedis();
   125→  if (!client) return null;
   126→
   127→  const key = `${maxRequests}:${windowMs}`;
   128→  let limiter = limiterCache.get(key);
   129→  if (limiter) return limiter;
   130→
   131→  const windowSec = Math.ceil(windowMs / 1000);
   132→  const windowStr = `${windowSec} s` as `${number} s`;
   133→
   134→  limiter = new Ratelimit({
   135→    redis: client,
   136→    limiter: Ratelimit.slidingWindow(maxRequests, windowStr),
   137→    prefix: "rr:rl",
   138→  });
   139→
   140→  limiterCache.set(key, limiter);
   141→  return limiter;
   142→}
   143→
   144→// ---------------------------------------------------------------------------
   145→// Public API (unchanged signatures)
   146→// ---------------------------------------------------------------------------
   147→
   148→export function getClientIP(request: Request): string {
   149→  const forwarded = request.headers.get("x-forwarded-for");
   150→  if (forwarded) return forwarded.split(",")[0].trim();
   151→  const realIP = request.headers.get("x-real-ip");
   152→  if (realIP) return realIP;
   153→  return "unknown";
   154→}
   155→
   156→export async function checkRateLimit(
   157→  identifier: string,
   158→  maxRequests?: number,
   159→  windowMs?: number
   160→): Promise<{
   161→  allowed: boolean;
   162→  remaining: number;
   163→  resetTime: number;
   164→  totalLimit: number;
   165→}> {
   166→  const limit = maxRequests ?? MAX_REQUESTS;
   167→  const window = windowMs ?? WINDOW_MS;
   168→
   169→  const limiter = getLimiter(limit, window);
   170→  if (limiter) {
   171→    try {
   172→      const result = await limiter.limit(identifier);
   173→      return {
   174→        allowed: result.success,
   175→        remaining: result.remaining,
   176→        resetTime: result.reset,
   177→        totalLimit: result.limit,
   178→      };
   179→    } catch {
   180→      // Redis failed — fall through to memory
   181→    }
   182→  }
   183→
   184→  return memoryCheckRateLimit(identifier, limit, window);
   185→}
   186→
   187→export function getRateLimitHeaders(result: {
   188→  allowed: boolean;
   189→  remaining: number;
   190→  resetTime: number;
   191→  totalLimit: number;
   192→}): Record<string, string> {
   193→  return {
   194→    "X-RateLimit-Limit": result.totalLimit.toString(),
   195→    "X-RateLimit-Remaining": Math.max(0, result.remaining).toString(),
   196→    "X-RateLimit-Reset": new Date(result.resetTime).toISOString(),
   197→  };
   198→}
   199→
   200→export function createRateLimitResponse(
   201→  _identifier: string,
   202→  retryAfterSeconds?: number
   203→): Response {
   204→  const headers = new Headers({
   205→    "Content-Type": "application/json",
   206→    "X-RateLimit-Limit": MAX_REQUESTS.toString(),
   207→    "X-RateLimit-Remaining": "0",
   208→  });
   209→
   210→  if (retryAfterSeconds) {
   211→    headers.set("Retry-After", retryAfterSeconds.toString());
   212→  }
   213→
   214→  return new Response(
   215→    JSON.stringify({
   216→      error: "Rate limit exceeded. Please try again later.",
   217→      retryAfter: retryAfterSeconds,
   218→    }),
   219→    { status: 429, headers }
   220→  );
   221→}
   222→
   223→// Auto-start memory cleanup
   224→startRateLimitCleanup();
   225→
   226→export { MAX_REQUESTS, WINDOW_MS };
   227→

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>


     1→import { createClient } from "@/lib/supabase/server";
     2→import { NextRequest, NextResponse } from "next/server";
     3→import { logger } from "@/lib/logger";
     4→import { checkRateLimit, getClientIP, createRateLimitResponse } from "@/lib/rate-limit";
     5→
     6→// 100 conversation list fetches per 15 min window per IP
     7→const MESSAGE_RATE_LIMIT = 100;
     8→const MESSAGE_WINDOW_MS = 15 * 60 * 1000;
     9→
    10→export async function GET(request: NextRequest) {
    11→  try {
    12→    const clientIP = getClientIP(request);
    13→    const rateLimit = checkRateLimit(`messages-list:${clientIP}`, MESSAGE_RATE_LIMIT, MESSAGE_WINDOW_MS);
    14→
    15→    if (!rateLimit.allowed) {
    16→      const retryAfter = Math.ceil((rateLimit.resetTime - Date.now()) / 1000);
    17→      return createRateLimitResponse(clientIP, retryAfter);
    18→    }
    19→
    20→    const supabase = await createClient();
    21→
    22→    // Check if user is authenticated
    23→    const { data: { user }, error: authError } = await supabase.auth.getUser();
    24→
    25→    if (authError || !user) {
    26→      return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
    27→    }
    28→
    29→    // Update last_seen_at
    30→    await supabase
    31→      .from('profiles')
    32→      .update({ last_seen_at: new Date().toISOString() })
    33→      .eq('id', user.id);
    34→
    35→    // Get conversation previews using the database function
    36→    const { data: conversations, error } = await supabase
    37→      .rpc('get_conversation_previews', { user_id: user.id });
    38→
    39→    if (error) {
    40→      logger.error("Error fetching conversations:", error);
    41→      return NextResponse.json(
    42→        { error: "Failed to fetch conversations" },
    43→        { status: 500 }
    44→      );
    45→    }
    46→
    47→    return NextResponse.json({ conversations: conversations || [] });
    48→  } catch (error) {
    49→    logger.error("Error in messages API:", error);
    50→    return NextResponse.json(
    51→      { error: "Internal server error" },
    52→      { status: 500 }
    53→    );
    54→  }
    55→}

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>


     1→import { createClient } from "@/lib/supabase/server";
     2→import { NextRequest, NextResponse } from "next/server";
     3→import { checkRateLimit, getClientIP, createRateLimitResponse } from "@/lib/rate-limit";
     4→import { logger } from "@/lib/logger";
     5→
     6→// 200 message fetches per 15 min window per IP (higher limit for active conversations)
     7→const GET_MESSAGES_RATE_LIMIT = 200;
     8→const GET_MESSAGES_WINDOW_MS = 15 * 60 * 1000;
     9→
    10→export async function GET(
    11→  request: NextRequest,
    12→  { params }: { params: Promise<{ id: string }> }
    13→) {
    14→  try {
    15→    const clientIP = getClientIP(request);
    16→    const rateLimit = checkRateLimit(`messages-get:${clientIP}`, GET_MESSAGES_RATE_LIMIT, GET_MESSAGES_WINDOW_MS);
    17→
    18→    if (!rateLimit.allowed) {
    19→      const retryAfter = Math.ceil((rateLimit.resetTime - Date.now()) / 1000);
    20→      return createRateLimitResponse(clientIP, retryAfter);
    21→    }
    22→
    23→    const { id: conversationId } = await params;
    24→    const supabase = await createClient();
    25→
    26→    // Parse pagination params from URL
    27→    const { searchParams } = new URL(request.url);
    28→    const cursor = searchParams.get('cursor'); // message ID to fetch before
    29→    const limit = Math.min(parseInt(searchParams.get('limit') || '100'), 100); // Max 100 messages per request
    30→
    31→    // Check if user is authenticated
    32→    const { data: { user }, error: authError } = await supabase.auth.getUser();
    33→
    34→    if (authError || !user) {
    35→      return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
    36→    }
    37→
    38→    // Verify user is a participant in this conversation
    39→    const { data: participant, error: participantError } = await supabase
    40→      .from('conversation_participants')
    41→      .select('id')
    42→      .eq('conversation_id', conversationId)
    43→      .eq('profile_id', user.id)
    44→      .single();
    45→
    46→    if (participantError || !participant) {
    47→      return NextResponse.json({ error: "Conversation not found" }, { status: 404 });
    48→    }
    49→
    50→    // Get conversation details with other participant info
    51→    const { data: conversationData, error: conversationError } = await supabase
    52→      .from('conversation_participants')
    53→      .select(`
    54→        profile_id,
    55→        profiles!inner(
    56→          id,
    57→          name,
    58→          display_name,
    59→          avatar_url,
    60→          type
    61→        )
    62→      `)
    63→      .eq('conversation_id', conversationId)
    64→      .neq('profile_id', user.id)
    65→      .single();
    66→
    67→    if (conversationError || !conversationData) {
    68→      return NextResponse.json(
    69→        { error: "Failed to fetch conversation details" },
    70→        { status: 500 }
    71→      );
    72→    }
    73→
    74→    // Get messages in this conversation with cursor pagination
    75→    let query = supabase
    76→      .from('messages')
    77→      .select(`
    78→        id,
    79→        content,
    80→        sender_id,
    81→        status,
    82→        created_at,
    83→        attachments
    84→      `)
    85→      .eq('conversation_id', conversationId)
    86→      .order('created_at', { ascending: true })
    87→      .limit(limit);
    88→
    89→    // If cursor provided, fetch messages after this cursor (chronologically)
    90→    if (cursor) {
    91→      // Get the timestamp of the cursor message
    92→      const { data: cursorMessage } = await supabase
    93→        .from('messages')
    94→        .select('created_at')
    95→        .eq('id', cursor)
    96→        .single();
    97→
    98→      if (cursorMessage) {
    99→        query = query.gt('created_at', cursorMessage.created_at);
   100→      }
   101→    }
   102→
   103→    const { data: messages, error: messagesError } = await query;
   104→
   105→    if (messagesError) {
   106→      return NextResponse.json(
   107→        { error: "Failed to fetch messages" },
   108→        { status: 500 }
   109→      );
   110→    }
   111→
   112→    // Update last_read_at and last_seen_at
   113→    const now = new Date().toISOString();
   114→    await supabase
   115→      .from('conversation_participants')
   116→      .update({ last_read_at: now })
   117→      .eq('conversation_id', conversationId)
   118→      .eq('profile_id', user.id);
   119→
   120→    await supabase
   121→      .from('profiles')
   122→      .update({ last_seen_at: now })
   123→      .eq('id', user.id);
   124→
   125→    // Cast profiles to single object (inner join guarantees single row)
   126→    const profile = conversationData.profiles as unknown as {
   127→      id: string;
   128→      name: string;
   129→      avatar_url: string | null;
   130→      type: string;
   131→    };
   132→
   133→    // Check if other user was active in the last 5 minutes
   134→    const { data: otherProfile } = await supabase
   135→      .from('profiles')
   136→      .select('last_seen_at')
   137→      .eq('id', profile.id)
   138→      .single();
   139→
   140→    const fiveMinAgo = Date.now() - 5 * 60 * 1000;
   141→    const isOnline = otherProfile?.last_seen_at
   142→      ? new Date(otherProfile.last_seen_at).getTime() > fiveMinAgo
   143→      : false;
   144→
   145→    // Determine if there are more messages (next cursor)
   146→    const hasMore = messages && messages.length === limit;
   147→    const nextCursor = hasMore && messages.length > 0
   148→      ? messages[messages.length - 1].id
   149→      : null;
   150→
   151→    return NextResponse.json({
   152→      conversation: {
   153→        id: conversationId,
   154→        otherParticipant: {
   155→          id: profile.id,
   156→          name: profile.name,
   157→          avatar: profile.avatar_url,
   158→          type: profile.type,
   159→          isOnline,
   160→        },
   161→        messages: messages?.map(msg => ({
   162→          id: msg.id,
   163→          content: msg.content,
   164→          isOwn: msg.sender_id === user.id,
   165→          timestamp: msg.created_at,
   166→          status: msg.status,
   167→          attachments: msg.attachments
   168→        })) || [],
   169→        pagination: {
   170→          nextCursor,
   171→          hasMore,
   172→          limit
   173→        }
   174→      }
   175→    });
   176→  } catch (error) {
   177→    logger.error("Error in conversation API:", error);
   178→    return NextResponse.json(
   179→      { error: "Internal server error" },
   180→      { status: 500 }
   181→    );
   182→  }
   183→}

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>


     1→import { createClient } from "@/lib/supabase/server";
     2→import { NextRequest, NextResponse } from "next/server";
     3→import { validateCSRFToken, isSameOrigin } from "@/lib/csrf";
     4→import { sanitizeInput } from "@/lib/sanitize";
     5→import { checkRateLimit, getClientIP, createRateLimitResponse } from "@/lib/rate-limit";
     6→import { logger } from "@/lib/logger";
     7→import { z } from "zod";
     8→import {
     9→  SEND_MESSAGE_RATE_LIMIT,
    10→  RATE_LIMIT_WINDOW_MS as SEND_MESSAGE_WINDOW_MS,
    11→  MAX_LENGTH_MESSAGE_CONTENT
    12→} from '@/lib/constants';
    13→
    14→// 60 messages per 15 min window per IP (prevents message spam)
    15→
    16→// Attachment validation schema
    17→const attachmentSchema = z.object({
    18→  url: z.string().url().max(2048),
    19→  filename: z.string().min(1).max(255),
    20→  contentType: z.string().regex(/^[a-z]+\/[a-z0-9\-\+\.]+$/i).max(127),
    21→  size: z.number().int().min(1).max(10 * 1024 * 1024), // 10MB max
    22→});
    23→
    24→const sendMessageSchema = z.object({
    25→  content: z.string().max(MAX_LENGTH_MESSAGE_CONTENT),
    26→  attachments: z.array(attachmentSchema).max(5).optional().default([]),
    27→});
    28→
    29→export async function POST(
    30→  request: NextRequest,
    31→  { params }: { params: Promise<{ id: string }> }
    32→) {
    33→  try {
    34→    const clientIP = getClientIP(request);
    35→    const rateLimit = checkRateLimit(`messages-send:${clientIP}`, SEND_MESSAGE_RATE_LIMIT, SEND_MESSAGE_WINDOW_MS);
    36→
    37→    if (!rateLimit.allowed) {
    38→      const retryAfter = Math.ceil((rateLimit.resetTime - Date.now()) / 1000);
    39→      return createRateLimitResponse(clientIP, retryAfter);
    40→    }
    41→
    42→    // CSRF protection: validate same-origin and token
    43→    if (!isSameOrigin(request)) {
    44→      return NextResponse.json({ error: 'Cross-origin request not allowed' }, { status: 403 });
    45→    }
    46→    const csrfValid = await validateCSRFToken(request);
    47→    if (!csrfValid) {
    48→      return NextResponse.json({ error: 'Invalid or missing CSRF token' }, { status: 403 });
    49→    }
    50→
    51→    const { id: conversationId } = await params;
    52→    const supabase = await createClient();
    53→
    54→    // Check if user is authenticated
    55→    const { data: { user }, error: authError } = await supabase.auth.getUser();
    56→
    57→    if (authError || !user) {
    58→      return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
    59→    }
    60→
    61→    // Parse and validate request body
    62→    const body = await request.json();
    63→    const validationResult = sendMessageSchema.safeParse(body);
    64→
    65→    if (!validationResult.success) {
    66→      return NextResponse.json(
    67→        {
    68→          error: "Invalid request data",
    69→          details: validationResult.error.issues.map(e => ({
    70→            field: e.path.join('.'),
    71→            message: e.message
    72→          }))
    73→        },
    74→        { status: 400 }
    75→      );
    76→    }
    77→
    78→    const { content, attachments } = validationResult.data;
    79→    const sanitizedContent = sanitizeInput(content, { maxLength: MAX_LENGTH_MESSAGE_CONTENT });
    80→
    81→    if (!sanitizedContent) {
    82→      return NextResponse.json(
    83→        { error: "Message content is required" },
    84→        { status: 400 }
    85→      );
    86→    }
    87→
    88→    // Verify user is a participant in this conversation
    89→    const { data: participant, error: participantError } = await supabase
    90→      .from('conversation_participants')
    91→      .select('id')
    92→      .eq('conversation_id', conversationId)
    93→      .eq('profile_id', user.id)
    94→      .single();
    95→
    96→    if (participantError || !participant) {
    97→      return NextResponse.json({ error: "Conversation not found" }, { status: 404 });
    98→    }
    99→
   100→    // Insert the message
   101→    const { data: message, error: messageError } = await supabase
   102→      .from('messages')
   103→      .insert({
   104→        conversation_id: conversationId,
   105→        sender_id: user.id,
   106→        content: sanitizedContent,
   107→        attachments: attachments
   108→      })
   109→      .select(`
   110→        id,
   111→        content,
   112→        sender_id,
   113→        status,
   114→        created_at,
   115→        attachments
   116→      `)
   117→      .single();
   118→
   119→    if (messageError) {
   120→      logger.error("Error sending message:", messageError);
   121→      return NextResponse.json(
   122→        { error: "Failed to send message" },
   123→        { status: 500 }
   124→      );
   125→    }
   126→
   127→    // Get the other participant to send notification
   128→    const { data: otherParticipant } = await supabase
   129→      .from('conversation_participants')
   130→      .select('profile_id')
   131→      .eq('conversation_id', conversationId)
   132→      .neq('profile_id', user.id)
   133→      .single();
   134→
   135→    if (otherParticipant) {
   136→      // Create notification for the other participant
   137→      const { data: senderProfile } = await supabase
   138→        .from('profiles')
   139→        .select('name')
   140→        .eq('id', user.id)
   141→        .single();
   142→
   143→      const { error: notifError } = await supabase.from('notifications').insert({
   144→        profile_id: otherParticipant.profile_id,
   145→        type: 'system',
   146→        title: 'New message',
   147→        body: `${senderProfile?.name || 'Someone'} sent you a message`,
   148→        data: {
   149→          conversationId: conversationId,
   150→          senderId: user.id
   151→        }
   152→      });
   153→      if (notifError) {
   154→        logger.error('Failed to create message notification:', notifError);
   155→      }
   156→    }
   157→
   158→    return NextResponse.json({
   159→      message: {
   160→        id: message.id,
   161→        content: message.content,
   162→        isOwn: true,
   163→        timestamp: message.created_at,
   164→        status: message.status,
   165→        attachments: message.attachments
   166→      }
   167→    });
   168→  } catch (error) {
   169→    logger.error("Error in send message API:", error);
   170→    return NextResponse.json(
   171→      { error: "Internal server error" },
   172→      { status: 500 }
   173→    );
   174→  }
   175→}

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>


     1→import { createClient } from "@/lib/supabase/server";
     2→import { NextRequest, NextResponse } from "next/server";
     3→import { logger } from "@/lib/logger";
     4→import { withCSRFProtection } from "@/lib/csrf";
     5→import { checkRateLimit, getClientIP, createRateLimitResponse } from "@/lib/rate-limit";
     6→
     7→// 20 conversation starts per 15 min window per IP (prevents spam conversation creation)
     8→const START_RATE_LIMIT = 20;
     9→const START_WINDOW_MS = 15 * 60 * 1000;
    10→
    11→const handler = async (request: NextRequest) => {
    12→  try {
    13→    const clientIP = getClientIP(request);
    14→    const rateLimit = checkRateLimit(`messages-start:${clientIP}`, START_RATE_LIMIT, START_WINDOW_MS);
    15→
    16→    if (!rateLimit.allowed) {
    17→      const retryAfter = Math.ceil((rateLimit.resetTime - Date.now()) / 1000);
    18→      return createRateLimitResponse(clientIP, retryAfter);
    19→    }
    20→
    21→    const supabase = await createClient();
    22→
    23→    // Check if user is authenticated
    24→    const { data: { user }, error: authError } = await supabase.auth.getUser();
    25→
    26→    if (authError || !user) {
    27→      return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
    28→    }
    29→
    30→    const { recipientId, initialMessage } = await request.json();
    31→
    32→    if (!recipientId) {
    33→      return NextResponse.json(
    34→        { error: "Recipient ID is required" },
    35→        { status: 400 }
    36→      );
    37→    }
    38→
    39→    // Verify recipient exists
    40→    const { data: recipient, error: recipientError } = await supabase
    41→      .from('profiles')
    42→      .select('id, name')
    43→      .eq('id', recipientId)
    44→      .single();
    45→
    46→    if (recipientError || !recipient) {
    47→      return NextResponse.json(
    48→        { error: "Recipient not found" },
    49→        { status: 404 }
    50→      );
    51→    }
    52→
    53→    // Verify hire relationship: match must be "hired" AND payment must be confirmed
    54→    const { data: hiredMatch, error: matchError } = await supabase
    55→      .from('matches')
    56→      .select(`
    57→        id,
    58→        status,
    59→        worker_id,
    60→        fee_charged,
    61→        jobs!inner(company_id, companies!inner(profile_id))
    62→      `)
    63→      .eq('status', 'hired')
    64→      .or(
    65→        `and(worker_id.eq.${user.id},jobs.companies.profile_id.eq.${recipientId}),` +
    66→        `and(worker_id.eq.${recipientId},jobs.companies.profile_id.eq.${user.id})`
    67→      )
    68→      .limit(1);
    69→
    70→    if (matchError) {
    71→      logger.error("Error checking hire status:", matchError);
    72→      return NextResponse.json(
    73→        { error: "Failed to verify hire status" },
    74→        { status: 500 }
    75→      );
    76→    }
    77→
    78→    if (!hiredMatch || hiredMatch.length === 0) {
    79→      return NextResponse.json(
    80→        { error: "Messaging is only available after a hire has been confirmed" },
    81→        { status: 403 }
    82→      );
    83→    }
    84→
    85→    // Verify payment has been charged for this match
    86→    const matchId = hiredMatch[0].id;
    87→    const { data: payment, error: paymentError } = await supabase
    88→      .from('payments')
    89→      .select('id, status')
    90→      .eq('match_id', matchId)
    91→      .eq('status', 'charged')
    92→      .limit(1);
    93→
    94→    if (paymentError) {
    95→      logger.error("Error checking payment status:", paymentError);
    96→      return NextResponse.json(
    97→        { error: "Failed to verify payment status" },
    98→        { status: 500 }
    99→      );
   100→    }
   101→
   102→    if (!payment || payment.length === 0) {
   103→      return NextResponse.json(
   104→        { error: "Messaging requires a confirmed payment. Please complete the hire payment first." },
   105→        { status: 403 }
   106→      );
   107→    }
   108→
   109→    // Get or create conversation using the database function
   110→    const { data: conversationId, error: conversationError } = await supabase
   111→      .rpc('get_or_create_conversation', {
   112→        user1_id: user.id,
   113→        user2_id: recipientId
   114→      });
   115→
   116→    if (conversationError) {
   117→      logger.error("Error creating conversation:", conversationError);
   118→      return NextResponse.json(
   119→        { error: "Failed to create conversation" },
   120→        { status: 500 }
   121→      );
   122→    }
   123→
   124→    // If there's an initial message, send it
   125→    if (initialMessage?.trim()) {
   126→      const { error: messageError } = await supabase
   127→        .from('messages')
   128→        .insert({
   129→          conversation_id: conversationId,
   130→          sender_id: user.id,
   131→          content: initialMessage.trim()
   132→        });
   133→
   134→      if (messageError) {
   135→        logger.error("Error sending initial message:", messageError);
   136→        // Don't fail the whole request if message fails
   137→      }
   138→    }
   139→
   140→    return NextResponse.json({
   141→      conversationId,
   142→      message: "Conversation created successfully"
   143→    });
   144→  } catch (error) {
   145→    logger.error("Error in start conversation API:", error);
   146→    return NextResponse.json(
   147→      { error: "Internal server error" },
   148→      { status: 500 }
   149→    );
   150→  }
   151→}
   152→
   153→// Apply CSRF protection to the handler
   154→export const POST = withCSRFProtection(handler);
   155→

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>


     1→import { formatABN } from "@/lib/abn-utils";
     2→import { NextRequest, NextResponse } from 'next/server';
     3→import { isValidABN, cleanABN } from '@/lib/abn-utils';
     4→import { checkRateLimit, getClientIP, createRateLimitResponse } from '@/lib/rate-limit';
     5→import { sanitizeInput } from '@/lib/sanitize';
     6→import { z } from 'zod';
     7→import {
     8→  MAX_LENGTH_EMAIL,
     9→  TIMEOUT_ABN_LOOKUP,
    10→  POSTCODE_SYDNEY,
    11→  POSTCODE_MELBOURNE
    12→} from '@/lib/constants';
    13→import { getCachedABN, cacheABNData } from '@/lib/abn-cache';
    14→import { logger } from '@/lib/logger';
    15→
    16→/**
    17→ * ABN Lookup API Route — GET /api/abn-lookup?abn=XXXXXXXXXXX
    18→ *
    19→ * Validates the ABN locally (checksum), then calls the ABR JSON endpoint
    20→ * to fetch entity details.
    21→ *
    22→ * ABR JSON docs: https://abr.business.gov.au/json/
    23→ * Registration:  https://abr.business.gov.au/Tools/WebServices
    24→ *
    25→ * Required env var:
    26→ *   ABR_GUID — the authentication GUID issued after registration
    27→ *
    28→ * Optional env var:
    29→ *   ABR_DEV_MODE — set to 'true' to enable development mode with mock data
    30→ */
    31→
    32→// ── ABR configuration ──────────────────────────────────────────────
    33→const ABR_JSON_URL = 'https://abr.business.gov.au/json/AbnDetails.aspx';
    34→const ABR_GUID = process.env.ABR_GUID ?? '';
    35→const ABR_DEV_MODE = process.env.ABR_DEV_MODE === 'true';
    36→
    37→// ── Types ──────────────────────────────────────────────────────────
    38→
    39→/** Shape returned by the ABR JSON endpoint (JSONP-unwrapped). */
    40→interface ABRJsonResponse {
    41→  Abn: string;
    42→  AbnStatus: string;
    43→  AbnStatusEffectiveFrom: string;
    44→  Acn: string;
    45→  AddressDate: string | null;
    46→  AddressPostcode: string;
    47→  AddressState: string;
    48→  BusinessName: Array<string | { Value: string; EffectiveFrom: string }>;
    49→  EntityName: string;
    50→  EntityTypeCode: string;
    51→  EntityTypeName: string;
    52→  Gst: string | null;
    53→  Message: string;
    54→}
    55→
    56→/** Director verification result */
    57→interface DirectorVerification {
    58→  status: 'pending' | 'verified' | 'mismatch' | 'not_available' | 'auto_verified' | 'manual_review';
    59→  message: string;
    60→  requiresManualReview?: boolean;
    61→  verificationMethod?: string;
    62→  riskScore?: number;
    63→  riskFactors?: string[];
    64→}
    65→
    66→/** Company data from ABR */
    67→interface CompanyData {
    68→  name: string;
    69→  abn: string;
    70→  status: string;
    71→  statusDate: string;
    72→  state: string;
    73→  postcode: string;
    74→  type: string;
    75→  typeCode: string;
    76→  gstRegistered: boolean;
    77→  acn?: string;
    78→  directorVerification?: DirectorVerification;
    79→}
    80→
    81→/** Our normalised response. */
    82→interface ABNLookupResult {
    83→  success: boolean;
    84→  data?: CompanyData;
    85→  error?: string;
    86→  devMode?: boolean;
    87→}
    88→
    89→// ── Development Mode Mock Data ─────────────────────────────────────
    90→
    91→/**
    92→ * Mock company data for development when ABR_GUID is not configured.
    93→ * Uses obviously fake ABNs to prevent confusion with real data.
    94→ */
    95→const MOCK_COMPANIES: Record<string, CompanyData> = {
    96→  // Test ABN for development - passes checksum but is obviously fake
    97→  '51824753556': {
    98→    name: 'TEST COMPANY PTY LTD (Dev Mode)',
    99→    abn: '51 824 753 556',
   100→    status: 'Active',
   101→    statusDate: '2020-01-01',
   102→    state: 'NSW',
   103→    postcode: POSTCODE_SYDNEY,
   104→    type: 'Australian Private Company',
   105→    typeCode: 'PRV',
   106→    gstRegistered: true,
   107→    directorVerification: {
   108→      status: 'auto_verified',
   109→      message: 'Development mode - auto-verified',
   110→      verificationMethod: 'dev_mode',
   111→      riskScore: 0,
   112→    },
   113→  },
   114→  // Another test ABN
   115→  '34241177887': {
   116→    name: 'EXAMPLE BUILDERS PTY LTD (Dev Mode)',
   117→    abn: '34 241 177 887',
   118→    status: 'Active',
   119→    statusDate: '2019-06-15',
   120→    state: 'VIC',
   121→    postcode: POSTCODE_MELBOURNE,
   122→    type: 'Australian Private Company',
   123→    typeCode: 'PRV',
   124→    gstRegistered: true,
   125→    directorVerification: {
   126→      status: 'auto_verified',
   127→      message: 'Development mode - auto-verified',
   128→      verificationMethod: 'dev_mode',
   129→      riskScore: 0,
   130→    },
   131→  },
   132→};
   133→
   134→/**
   135→ * Generate mock data for any valid ABN in development mode.
   136→ */
   137→function generateMockData(abn: string): CompanyData {
   138→  const formatted = formatABN(abn);
   139→  return {
   140→    name: `MOCK COMPANY ${abn.slice(-4)} PTY LTD (Dev Mode)`,
   141→    abn: formatted,
   142→    status: 'Active',
   143→    statusDate: new Date().toISOString().split('T')[0],
   144→    state: 'NSW',
   145→    postcode: POSTCODE_SYDNEY,
   146→    type: 'Australian Private Company',
   147→    typeCode: 'PRV',
   148→    gstRegistered: Math.random() > 0.3,
   149→    directorVerification: {
   150→      status: 'auto_verified',
   151→      message: 'Development mode - auto-verified',
   152→      verificationMethod: 'dev_mode',
   153→      riskScore: 0,
   154→    },
   155→  };
   156→}
   157→
   158→// ── Helpers ────────────────────────────────────────────────────────
   159→
   160→function buildABRUrl(abn: string): string {
   161→  const params = new URLSearchParams({
   162→    abn,
   163→    guid: ABR_GUID,
   164→    callback: 'cbr',
   165→  });
   166→  return `${ABR_JSON_URL}?${params.toString()}`;
   167→}
   168→
   169→function stripJSONP(jsonp: string): ABRJsonResponse {
   170→  const match = jsonp.match(/^cbr\((.*)\)$/);
   171→  if (!match) throw new Error('Unexpected ABR response format');
   172→  return JSON.parse(match[1]);
   173→}
   174→
   175→function normaliseABRResponse(abr: ABRJsonResponse): CompanyData {
   176→  const name = abr.EntityName ||
   177→    (Array.isArray(abr.BusinessName) && abr.BusinessName[0]) ||
   178→    'Unknown';
   179→
   180→  return {
   181→    name: typeof name === 'string' ? name : (name as any).Value || 'Unknown',
   182→    abn: formatABN(abr.Abn),
   183→    status: abr.AbnStatus,
   184→    statusDate: abr.AbnStatusEffectiveFrom,
   185→    state: abr.AddressState,
   186→    postcode: abr.AddressPostcode,
   187→    type: abr.EntityTypeName,
   188→    typeCode: abr.EntityTypeCode,
   189→    gstRegistered: abr.Gst !== null,
   190→    acn: abr.Acn || undefined,
   191→    // Director verification will be done by verifyDirector() function
   192→  };
   193→}
   194→
   195→/**
   196→ * Multi-Layer Director Verification
   197→ *
   198→ * Implements a practical 3-tier verification system:
   199→ * 1. Automated checks (email domain, GST status, entity type)
   200→ * 2. Risk scoring to flag suspicious registrations
   201→ * 3. Manual review fallback for high-risk cases
   202→ *
   203→ * Future: ASIC Connect API integration for real director lookup
   204→ * @see https://connectonline.asic.gov.au/
   205→ */
   206→async function verifyDirector(
   207→  abn: string,
   208→  companyData: CompanyData,
   209→  userEmail?: string
   210→): Promise<DirectorVerification> {
   211→  const riskFactors: string[] = [];
   212→  let riskScore = 0;
   213→
   214→  // --- Layer 1: Automated Verification Checks ---
   215→
   216→  // Check 1: GST Registration (legitimate businesses usually have GST)
   217→  if (!companyData.gstRegistered) {
   218→    riskScore += 15;
   219→    riskFactors.push('Not GST registered');
   220→  }
   221→
   222→  // Check 2: Entity Type (only allow private companies and sole traders)
   223→  const allowedEntityTypes = ['PRV', 'IND']; // Private Company, Individual/Sole Trader
   224→  if (!allowedEntityTypes.includes(companyData.typeCode)) {
   225→    riskScore += 30;
   226→    riskFactors.push(`Unusual entity type: ${companyData.type}`);
   227→  }
   228→
   229→  // Check 3: ABN Status (must be active)
   230→  if (companyData.status.toLowerCase() !== 'active') {
   231→    riskScore += 50;
   232→    riskFactors.push(`ABN status: ${companyData.status}`);
   233→  }
   234→
   235→  // Check 4: Email Domain Validation (if provided)
   236→  if (userEmail) {
   237→    const emailDomain = userEmail.split('@')[1]?.toLowerCase();
   238→    const companyName = companyData.name.toLowerCase();
   239→
   240→    // Check if email is free provider (gmail, outlook, etc.)
   241→    const freeEmailProviders = [
   242→      'gmail.com', 'outlook.com', 'hotmail.com', 'yahoo.com',
   243→      'icloud.com', 'live.com', 'msn.com', 'protonmail.com'
   244→    ];
   245→
   246→    if (freeEmailProviders.includes(emailDomain)) {
   247→      riskScore += 20;
   248→      riskFactors.push('Using free email provider (no company domain)');
   249→    } else {
   250→      // Check if email domain roughly matches company name
   251→      const domainParts = emailDomain.replace(/\.com\.au|\.com|\.net\.au|\.net|\.org\.au|\.org/g, '').split('.');
   252→      const companyParts = companyName.replace(/pty ltd|pty|ltd|limited|proprietary/gi, '').trim().split(/\s+/);
   253→
   254→      // Simple fuzzy match: check if any significant word from company name is in domain
   255→      const hasMatch = companyParts.some(part =>
   256→        part.length > 3 && domainParts.some(dp =>
   257→          dp.includes(part.toLowerCase()) || part.toLowerCase().includes(dp)
   258→        )
   259→      );
   260→
   261→      if (!hasMatch) {
   262→        riskScore += 25;
   263→        riskFactors.push('Email domain does not match company name');
   264→      }
   265→    }
   266→  } else {
   267→    // No email provided for verification
   268→    riskScore += 10;
   269→    riskFactors.push('No email provided for verification');
   270→  }
   271→
   272→  // Check 5: Company Age (ABN status date - newly registered companies are riskier)
   273→  const statusDate = new Date(companyData.statusDate);
   274→  const monthsOld = (Date.now() - statusDate.getTime()) / (1000 * 60 * 60 * 24 * 30);
   275→
   276→  if (monthsOld < 3) {
   277→    riskScore += 20;
   278→    riskFactors.push('Company registered less than 3 months ago');
   279→  } else if (monthsOld < 12) {
   280→    riskScore += 10;
   281→    riskFactors.push('Company registered less than 1 year ago');
   282→  }
   283→
   284→  // Check 6: Missing ACN (private companies should have ACN)
   285→  if (companyData.typeCode === 'PRV' && !companyData.acn) {
   286→    riskScore += 15;
   287→    riskFactors.push('Private company missing ACN');
   288→  }
   289→
   290→  // --- Layer 2: Risk Score Evaluation ---
   291→
   292→  // Low Risk (0-25): Auto-verify
   293→  if (riskScore <= 25 && companyData.status.toLowerCase() === 'active') {
   294→    return {
   295→      status: 'auto_verified',
   296→      message: 'Company automatically verified based on ABR data and basic checks',
   297→      verificationMethod: 'automated',
   298→      riskScore,
   299→      riskFactors: riskFactors.length > 0 ? riskFactors : undefined,
   300→      requiresManualReview: false,
   301→    };
   302→  }
   303→
   304→  // Medium Risk (26-50): Flag for manual review but allow signup
   305→  if (riskScore <= 50) {
   306→    return {
   307→      status: 'manual_review',
   308→      message: 'Company registration flagged for manual review. You can proceed with signup.',
   309→      verificationMethod: 'manual_review_required',
   310→      riskScore,
   311→      riskFactors,
   312→      requiresManualReview: true,
   313→    };
   314→  }
   315→
   316→  // High Risk (51+): Require manual approval before activation
   317→  return {
   318→    status: 'pending',
   319→    message: 'Company registration requires manual verification before activation. Our team will review within 24 hours.',
   320→    verificationMethod: 'manual_approval_required',
   321→    riskScore,
   322→    riskFactors,
   323→    requiresManualReview: true,
   324→  };
   325→}
   326→
   327→// ── Route Handler ──────────────────────────────────────────────────
   328→
   329→// Rate limit: 10 requests per 15 minutes per IP (external API call)
   330→const ABN_LOOKUP_RATE_LIMIT = 10;
   331→const ABN_LOOKUP_WINDOW_MS = 15 * 60 * 1000;
   332→
   333→// Email validation schema
   334→const emailSchema = z.string().email().max(254);
   335→
   336→export async function GET(request: NextRequest) {
   337→  try {
   338→    // --- 0. Rate limiting ---------------------------------------------------
   339→    const clientIP = getClientIP(request);
   340→    const rateLimit = checkRateLimit(`abn-lookup:${clientIP}`, ABN_LOOKUP_RATE_LIMIT, ABN_LOOKUP_WINDOW_MS);
   341→
   342→    if (!rateLimit.allowed) {
   343→      const retryAfter = Math.ceil((rateLimit.resetTime - Date.now()) / 1000);
   344→      return createRateLimitResponse(clientIP, retryAfter);
   345→    }
   346→
   347→    const searchParams = request.nextUrl.searchParams;
   348→    const rawABN = searchParams.get('abn');
   349→    const rawEmail = searchParams.get('email');
   350→
   351→    // --- 1. Validate input --------------------------------------------------
   352→    if (!rawABN) {
   353→      return NextResponse.json({
   354→        success: false,
   355→        error: 'Missing query parameter: abn'
   356→      }, { status: 400 });
   357→    }
   358→
   359→    const abn = cleanABN(rawABN);
   360→    if (!isValidABN(abn)) {
   361→      return NextResponse.json({
   362→        success: false,
   363→        error: 'Invalid ABN format. Please check the number and try again.'
   364→      }, { status: 400 });
   365→    }
   366→
   367→    // Validate email format if provided
   368→    let userEmail: string | null = null;
   369→    if (rawEmail) {
   370→      const sanitizedEmail = sanitizeInput(rawEmail, { maxLength: MAX_LENGTH_EMAIL });
   371→      const emailValidation = emailSchema.safeParse(sanitizedEmail);
   372→
   373→      if (!emailValidation.success) {
   374→        return NextResponse.json({
   375→          success: false,
   376→          error: 'Invalid email format. Please provide a valid email address.'
   377→        }, { status: 400 });
   378→      }
   379→
   380→      userEmail = emailValidation.data;
   381→    }
   382→
   383→    // --- 2. Check cache first ----------------------------------------------
   384→    const cachedData = await getCachedABN(abn);
   385→    if (cachedData) {
   386→      logger.info(`[ABN Lookup] Cache hit for ABN: ${abn}`);
   387→
   388→      // Convert cached data to the expected format
   389→      const result: CompanyData & { directorVerification?: DirectorVerification } = {
   390→        name: cachedData.companyName,
   391→        abn: cachedData.abn,
   392→        status: cachedData.status,
   393→        statusDate: cachedData.statusDate || '',
   394→        state: cachedData.state || '',
   395→        postcode: cachedData.postcode || '',
   396→        type: cachedData.entityType || '',
   397→        typeCode: cachedData.entityTypeCode || '',
   398→        gstRegistered: cachedData.gstRegistered || false,
   399→        acn: cachedData.acn,
   400→      };
   401→
   402→      // Director verification (same as before)
   403→      result.directorVerification = await verifyDirector(
   404→        abn,
   405→        result,
   406→        userEmail || undefined
   407→      );
   408→
   409→      return NextResponse.json({
   410→        success: true,
   411→        data: result,
   412→        cached: true,
   413→        cachedAt: cachedData.cachedAt,
   414→      });
   415→    }
   416→
   417→    logger.info(`[ABN Lookup] Cache miss for ABN: ${abn}, calling ABR API`);
   418→
   419→    // --- 3. Check if ABR_GUID is configured --------------------------------
   420→    const isGuidConfigured = ABR_GUID &&
   421→      ABR_GUID !== 'REGISTER-TOMORROW-AT-ABR-BUSINESS-GOV-AU' &&
   422→      ABR_GUID !== 'temporary-abr-guid-for-build';
   423→
   424→    if (!isGuidConfigured) {
   425→      // Development mode: return mock data
   426→      if (ABR_DEV_MODE) {
   427→        logger.info(`[ABN Lookup] Dev mode - returning mock data for ${abn}`);
   428→        const mockData = MOCK_COMPANIES[abn] || generateMockData(abn);
   429→        return NextResponse.json({
   430→          success: true,
   431→          data: mockData,
   432→          devMode: true,
   433→        });
   434→      }
   435→
   436→      // Production without GUID: return error with validation info
   437→      return NextResponse.json({
   438→        success: false,
   439→        error: 'ABR service not configured',
   440→        devMode: false,
   441→      }, { status: 503 });
   442→    }
   443→
   444→    // --- 4. Call ABR web service -------------------------------------------
   445→    const url = buildABRUrl(abn);
   446→    const abrResponse = await fetch(url, {
   447→      method: 'GET',
   448→      headers: { 'User-Agent': 'RateRight/1.0' },
   449→      signal: AbortSignal.timeout(TIMEOUT_ABN_LOOKUP),
   450→    });
   451→
   452→    if (!abrResponse.ok) {
   453→      const text = await abrResponse.text();
   454→      logger.error(`[ABN Lookup] ABR error ${abrResponse.status}:`, text);
   455→      return NextResponse.json({
   456→        success: false,
   457→        error: `ABR service error: ${abrResponse.status}`,
   458→      }, { status: 502 });
   459→    }
   460→
   461→    const jsonpText = await abrResponse.text();
   462→    const abrData = stripJSONP(jsonpText);
   463→
   464→    // --- 5. Handle ABR business logic ---------------------------------------
   465→    if (abrData.Message) {
   466→      // Cache the "not found" result with shorter expiry
   467→      await cacheABNData({
   468→        abn: abn,
   469→        companyName: 'Not Found',
   470→        status: 'Not Found',
   471→        abrResponse: abrData,
   472→      });
   473→
   474→      return NextResponse.json({
   475→        success: false,
   476→        error: abrData.Message
   477→      }, { status: 404 });
   478→    }
   479→
   480→    const result: CompanyData & { directorVerification?: DirectorVerification } = normaliseABRResponse(abrData);
   481→
   482→    // Cache the successful result
   483→    await cacheABNData({
   484→      abn: result.abn,
   485→      companyName: result.name,
   486→      status: result.status,
   487→      statusDate: result.statusDate,
   488→      state: result.state,
   489→      postcode: result.postcode,
   490→      entityType: result.type,
   491→      entityTypeCode: result.typeCode,
   492→      gstRegistered: result.gstRegistered,
   493→      acn: result.acn,
   494→      abrResponse: abrData,
   495→    });
   496→
   497→    // --- 5. Director verification (multi-layer automated + risk scoring) ----
   498→    result.directorVerification = await verifyDirector(
   499→      abn,
   500→      result,
   501→      userEmail || undefined
   502→    );
   503→
   504→    return NextResponse.json({
   505→      success: true,
   506→      data: result,
   507→    });
   508→
   509→  } catch (err: unknown) {
   510→    logger.error('[ABN Lookup] Uncaught error:', err);
   511→    return NextResponse.json({
   512→      success: false,
   513→      error: 'Internal server error',
   514→    }, { status: 500 });
   515→  }
   516→}
   517→

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>


     1→import { NextRequest, NextResponse } from "next/server";
     2→import { logger } from "@/lib/logger";
     3→import { createClient, getSupabaseAdmin } from "@/lib/supabase/server";
     4→import { checkRateLimit, getClientIP, createRateLimitResponse } from "@/lib/rate-limit";
     5→import { withCSRFProtection } from "@/lib/csrf";
     6→
     7→// Rate limit: 10 requests per 15 minutes
     8→const RATE_LIMIT = 10;
     9→const WINDOW_MS = 15 * 60 * 1000;
    10→
    11→/**
    12→ * POST /api/match/find-matches
    13→ *
    14→ * Finds and creates suggested matches for a job using DB-side scoring.
    15→ * Body: { job_id: string, limit?: number, min_score?: number }
    16→ *
    17→ * Scoring weights (calculated in database):
    18→ * - Trade match: 40%
    19→ * - Availability: 20%
    20→ * - Experience: 15%
    21→ * - Certifications: 15%
    22→ * - Location proximity: 10%
    23→ * - Ratings: 10%
    24→ *
    25→ * NOTE: This uses the find_matching_workers() RPC function which scales
    26→ * efficiently to 10k+ workers by performing all scoring calculations
    27→ * database-side with proper indexes.
    28→ */
    29→const handler = async (request: NextRequest) => {
    30→  try {
    31→    // Rate limiting
    32→    const clientIP = getClientIP(request);
    33→    const rateLimit = checkRateLimit(`match:${clientIP}`, RATE_LIMIT, WINDOW_MS);
    34→    if (!rateLimit.allowed) {
    35→      const retryAfter = Math.ceil((rateLimit.resetTime - Date.now()) / 1000);
    36→      return createRateLimitResponse(clientIP, retryAfter);
    37→    }
    38→
    39→    // Auth check
    40→    const supabase = await createClient();
    41→    const { data: { user }, error: authError } = await supabase.auth.getUser();
    42→    if (authError || !user) {
    43→      return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
    44→    }
    45→
    46→    const { job_id: jobId, limit = 20, min_score: minScore } = await request.json();
    47→    if (!jobId) {
    48→      return NextResponse.json({ error: "job_id is required" }, { status: 400 });
    49→    }
    50→
    51→    // Validate limit (prevent abuse)
    52→    const validLimit = Math.min(Math.max(parseInt(limit as string) || 20, 1), 50);
    53→    const validMinScore = Math.min(Math.max(parseInt(minScore as string) || 20, 0), 100);
    54→
    55→    // Use admin client to bypass RLS for matching operations
    56→    const admin = getSupabaseAdmin();
    57→
    58→    // Verify job exists and user owns it
    59→    const { data: job, error: jobError } = await admin
    60→      .from("jobs")
    61→      .select("id, status, companies!inner(profile_id)")
    62→      .eq("id", jobId)
    63→      .single();
    64→
    65→    if (jobError || !job) {
    66→      return NextResponse.json({ error: "Job not found" }, { status: 404 });
    67→    }
    68→
    69→    // Verify the requesting user owns this job's company
    70→    const companies = job.companies as unknown as { profile_id: string } | null;
    71→    if (!companies || companies.profile_id !== user.id) {
    72→      return NextResponse.json({ error: "Not authorized for this job" }, { status: 403 });
    73→    }
    74→
    75→    if (job.status !== "active") {
    76→      return NextResponse.json({ error: "Job is not active" }, { status: 400 });
    77→    }
    78→
    79→    // Call the DB-side scoring RPC function
    80→    // This is the key optimization - all scoring happens in the database
    81→    const { data: scoredWorkers, error: rpcError } = await admin.rpc(
    82→      "find_matching_workers",
    83→      {
    84→        p_job_id: jobId,
    85→        p_limit: validLimit,
    86→        p_min_score: validMinScore,
    87→      }
    88→    );
    89→
    90→    if (rpcError) {
    91→      logger.error("RPC error finding matches:", rpcError);
    92→      return NextResponse.json(
    93→        { error: "Failed to find matches", details: rpcError.message },
    94→        { status: 500 }
    95→      );
    96→    }
    97→
    98→    if (!scoredWorkers || scoredWorkers.length === 0) {
    99→      return NextResponse.json({
   100→        matches: [],
   101→        message: "No matching workers found",
   102→        matches_created: 0,
   103→      });
   104→    }
   105→
   106→    // Prepare match rows for insertion
   107→    const matchRows = scoredWorkers.map((match: {
   108→      worker_id: string;
   109→      score: number;
   110→      summary: string;
   111→    }) => ({
   112→      job_id: jobId,
   113→      worker_id: match.worker_id,
   114→      status: "suggested" as const,
   115→      match_score: match.score,
   116→      ai_summary: match.summary,
   117→      contractor_action: "pending" as const,
   118→      worker_action: "pending" as const,
   119→    }));
   120→
   121→    // Insert matches into the database
   122→    const { error: insertError } = await admin
   123→      .from("matches")
   124→      .upsert(matchRows, { onConflict: "job_id,worker_id", ignoreDuplicates: true });
   125→
   126→    if (insertError) {
   127→      logger.error("Error inserting matches:", insertError);
   128→      return NextResponse.json({ error: "Failed to save matches" }, { status: 500 });
   129→    }
   130→
   131→    return NextResponse.json({
   132→      matches: scoredWorkers.map((m: {
   133→        worker_id: string;
   134→        score: number;
   135→        summary: string;
   136→        reasons: string[];
   137→        profile: Record<string, unknown>;
   138→      }) => ({
   139→        worker_id: m.worker_id,
   140→        score: m.score,
   141→        summary: m.summary,
   142→        reasons: m.reasons,
   143→        profile: m.profile,
   144→      })),
   145→      matches_created: scoredWorkers.length,
   146→    });
   147→  } catch (error) {
   148→    logger.error("Match finding error:", error);
   149→    return NextResponse.json(
   150→      { error: "Failed to find matches" },
   151→      { status: 500 }
   152→    );
   153→  }
   154→}
   155→
   156→// Apply CSRF protection to the handler
   157→export const POST = withCSRFProtection(handler);
   158→

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>


     1→import { NextRequest, NextResponse } from 'next/server';
     2→import { logger } from '@/lib/logger';
     3→import { checkRateLimit, getClientIP, createRateLimitResponse } from '@/lib/rate-limit';
     4→
     5→// Cache to store logo URLs (in production, use Redis or database)
     6→const logoCache = new Map<string, { logoUrl: string | null; source: string; timestamp: number }>();
     7→const CACHE_DURATION = 24 * 60 * 60 * 1000; // 24 hours
     8→
     9→interface LogoResponse {
    10→  logoUrl: string | null;
    11→  source: 'clearbit' | 'google' | 'none';
    12→}
    13→
    14→// Rate limit: 30 requests per 15 minutes per IP
    15→const LOGO_RATE_LIMIT = 30;
    16→const LOGO_WINDOW_MS = 15 * 60 * 1000;
    17→
    18→async function checkLogoExists(url: string): Promise<boolean> {
    19→  try {
    20→    const response = await fetch(url, {
    21→      method: 'HEAD',
    22→      headers: {
    23→        'User-Agent': 'RateRight/1.0',
    24→      },
    25→    });
    26→    return response.ok;
    27→  } catch {
    28→    return false;
    29→  }
    30→}
    31→
    32→export async function GET(request: NextRequest) {
    33→  try {
    34→    // --- Rate limiting ---
    35→    const clientIP = getClientIP(request);
    36→    const rateLimit = checkRateLimit(`company-logo:${clientIP}`, LOGO_RATE_LIMIT, LOGO_WINDOW_MS);
    37→
    38→    if (!rateLimit.allowed) {
    39→      const retryAfter = Math.ceil((rateLimit.resetTime - Date.now()) / 1000);
    40→      return createRateLimitResponse(clientIP, retryAfter);
    41→    }
    42→
    43→    const searchParams = request.nextUrl.searchParams;
    44→    const domain = searchParams.get('domain');
    45→
    46→    if (!domain) {
    47→      return NextResponse.json<LogoResponse>(
    48→        { logoUrl: null, source: 'none' },
    49→        { status: 400, headers: getCORSHeaders(request) }
    50→      );
    51→    }
    52→
    53→    // Clean the domain (remove protocol if present)
    54→    const cleanDomain = domain.replace(/^https?:\/\//, '').replace(/\/.*$/, '');
    55→
    56→    // Check cache first
    57→    const cacheKey = cleanDomain;
    58→    const cached = logoCache.get(cacheKey);
    59→    if (cached && Date.now() - cached.timestamp < CACHE_DURATION) {
    60→      return NextResponse.json<LogoResponse>({
    61→        logoUrl: cached.logoUrl,
    62→        source: cached.source as LogoResponse['source'],
    63→      }, {
    64→        headers: getCORSHeaders(request),
    65→      });
    66→    }
    67→
    68→    // Try Clearbit Logo API first
    69→    const clearbitUrl = `https://logo.clearbit.com/${cleanDomain}`;
    70→    const clearbitExists = await checkLogoExists(clearbitUrl);
    71→
    72→    if (clearbitExists) {
    73→      logoCache.set(cacheKey, {
    74→        logoUrl: clearbitUrl,
    75→        source: 'clearbit',
    76→        timestamp: Date.now(),
    77→      });
    78→
    79→      return NextResponse.json<LogoResponse>({
    80→        logoUrl: clearbitUrl,
    81→        source: 'clearbit',
    82→      }, {
    83→        headers: getCORSHeaders(request),
    84→      });
    85→    }
    86→
    87→    // Try Google Favicon as fallback
    88→    const googleUrl = `https://www.google.com/s2/favicons?domain=${cleanDomain}&sz=128`;
    89→    const googleExists = await checkLogoExists(googleUrl);
    90→
    91→    if (googleExists) {
    92→      logoCache.set(cacheKey, {
    93→        logoUrl: googleUrl,
    94→        source: 'google',
    95→        timestamp: Date.now(),
    96→      });
    97→
    98→      return NextResponse.json<LogoResponse>({
    99→        logoUrl: googleUrl,
   100→        source: 'google',
   101→      }, {
   102→        headers: getCORSHeaders(request),
   103→      });
   104→    }
   105→
   106→    // No logo found
   107→    logoCache.set(cacheKey, {
   108→      logoUrl: null,
   109→      source: 'none',
   110→      timestamp: Date.now(),
   111→    });
   112→
   113→    return NextResponse.json<LogoResponse>({
   114→      logoUrl: null,
   115→      source: 'none',
   116→    }, {
   117→      headers: getCORSHeaders(request),
   118→    });
   119→
   120→  } catch (error) {
   121→    logger.error('Logo fetch error:', error);
   122→
   123→    return NextResponse.json<LogoResponse>(
   124→      { logoUrl: null, source: 'none' },
   125→      { status: 500, headers: getCORSHeaders(request) }
   126→    );
   127→  }
   128→}
   129→
   130→// CORS configuration - restrict to known origins
   131→const ALLOWED_ORIGINS = [
   132→  process.env.NEXT_PUBLIC_APP_URL, // Production URL from env
   133→  ...(process.env.NODE_ENV === 'development'
   134→    ? ['http://localhost:3000', 'https://localhost:3000']
   135→    : []),
   136→].filter(Boolean) as string[];
   137→
   138→function getCORSHeaders(request: NextRequest): Record<string, string> {
   139→  const origin = request.headers.get('origin');
   140→
   141→  // Only allow specific origins, no wildcards (H5 fix)
   142→  const allowedOrigin = origin && ALLOWED_ORIGINS.includes(origin)
   143→    ? origin
   144→    : ALLOWED_ORIGINS[0] || 'null';
   145→
   146→  return {
   147→    'Access-Control-Allow-Origin': allowedOrigin,
   148→    'Access-Control-Allow-Methods': 'GET, OPTIONS',
   149→    'Access-Control-Allow-Headers': 'Content-Type',
   150→    'Vary': 'Origin',
   151→  };
   152→}
   153→
   154→// Add OPTIONS handler for CORS
   155→export async function OPTIONS(request: NextRequest) {
   156→  return new NextResponse(null, {
   157→    status: 200,
   158→    headers: getCORSHeaders(request),
   159→  });
   160→}

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>


     1→import { NextRequest, NextResponse } from "next/server";
     2→import { logger } from "@/lib/logger";
     3→import OpenAI from "openai";
     4→import { createClient } from "@/lib/supabase/server";
     5→import { checkRateLimit, getClientIP, createRateLimitResponse } from "@/lib/rate-limit";
     6→import { sanitizeInput, stripContactInfo } from "@/lib/sanitize";
     7→import { withCSRFProtection } from "@/lib/csrf";
     8→import {
     9→  AI_RATE_LIMIT,
    10→  RATE_LIMIT_WINDOW_MS as AI_WINDOW_MS,
    11→  MAX_LENGTH_TRADE
    12→} from "@/lib/constants";
    13→
    14→if (!process.env.OPENAI_API_KEY) {
    15→  throw new Error("OPENAI_API_KEY environment variable is not set");
    16→}
    17→
    18→const openai = new OpenAI({
    19→  apiKey: process.env.OPENAI_API_KEY,
    20→});
    21→
    22→// Rate limit: 10 requests per 15 minutes per user
    23→
    24→const handler = async (request: NextRequest) => {
    25→  try {
    26→    const clientIP = getClientIP(request);
    27→    const rateLimit = checkRateLimit(`ai-job:${clientIP}`, AI_RATE_LIMIT, AI_WINDOW_MS);
    28→
    29→    if (!rateLimit.allowed) {
    30→      const retryAfter = Math.ceil((rateLimit.resetTime - Date.now()) / 1000);
    31→      return createRateLimitResponse(clientIP, retryAfter);
    32→    }
    33→
    34→    const supabase = await createClient();
    35→    const { data: { user }, error: authError } = await supabase.auth.getUser();
    36→    if (authError || !user) {
    37→      return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
    38→    }
    39→
    40→    const { trade } = await request.json();
    41→
    42→    if (!trade) {
    43→      return NextResponse.json({ error: "Missing trade" }, { status: 400 });
    44→    }
    45→
    46→    const sanitizedTrade = sanitizeInput(trade, { maxLength: MAX_LENGTH_TRADE });
    47→
    48→    const completion = await openai.chat.completions.create({
    49→      model: "gpt-4o-mini",
    50→      messages: [
    51→        {
    52→          role: "system",
    53→          content: "You are an Australian construction industry expert. Always respond with valid JSON only.",
    54→        },
    55→        {
    56→          role: "user",
    57→          content: `Generate a job posting for a ${sanitizedTrade} in the Australian construction industry.
    58→
    59→Return JSON with:
    60→1. title: A concise job title (e.g. "Experienced Steel Fixer Needed")
    61→2. description: A 3-4 sentence job description mentioning key duties, site expectations, and any specific requirements. Use Australian English. Mention PPE, early starts if relevant.
    62→3. certifications: Array of required certifications (e.g. "White Card", "Working at Heights", "Trade Certificate", "Scaffolding Licence"). Always include "White Card".
    63→4. rateMin: Minimum hourly rate in AUD (realistic 2026 Australian market rate)
    64→5. rateMax: Maximum hourly rate in AUD
    65→6. rateType: "hourly" or "daily"
    66→
    67→Return ONLY valid JSON:
    68→{
    69→  "title": "string",
    70→  "description": "string",
    71→  "certifications": ["string"],
    72→  "rateMin": number,
    73→  "rateMax": number,
    74→  "rateType": "hourly"
    75→}`,
    76→        },
    77→      ],
    78→      temperature: 0.7,
    79→      response_format: { type: "json_object" },
    80→    });
    81→
    82→    const content = completion.choices[0]?.message?.content;
    83→    if (!content) {
    84→      throw new Error("No response from AI");
    85→    }
    86→
    87→    const generated = JSON.parse(content);
    88→
    89→    // Validate and sanitize rate values
    90→    const MIN_HOURLY_RATE = 25; // Australian minimum for construction
    91→    const MAX_HOURLY_RATE = 200; // Upper bound for specialist trades
    92→    const DEFAULT_MIN_RATE = 35;
    93→    const DEFAULT_MAX_RATE = 55;
    94→
    95→    let rateMin = typeof generated.rateMin === 'number' ? generated.rateMin : DEFAULT_MIN_RATE;
    96→    let rateMax = typeof generated.rateMax === 'number' ? generated.rateMax : DEFAULT_MAX_RATE;
    97→
    98→    // Clamp rates to reasonable bounds
    99→    rateMin = Math.max(MIN_HOURLY_RATE, Math.min(rateMin, MAX_HOURLY_RATE));
   100→    rateMax = Math.max(MIN_HOURLY_RATE, Math.min(rateMax, MAX_HOURLY_RATE));
   101→
   102→    // Ensure rateMax >= rateMin
   103→    if (rateMax < rateMin) {
   104→      [rateMin, rateMax] = [rateMax, rateMin]; // Swap if reversed
   105→    }
   106→
   107→    // Ensure meaningful range (at least $5/hr difference)
   108→    if (rateMax - rateMin < 5) {
   109→      rateMax = rateMin + 10;
   110→      // Re-clamp if we exceeded max
   111→      if (rateMax > MAX_HOURLY_RATE) {
   112→        rateMax = MAX_HOURLY_RATE;
   113→        rateMin = Math.max(MIN_HOURLY_RATE, rateMax - 10);
   114→      }
   115→    }
   116→
   117→    return NextResponse.json({
   118→      title: stripContactInfo(generated.title || ""),
   119→      description: stripContactInfo(generated.description || ""),
   120→      certifications: generated.certifications || ["White Card"],
   121→      rateMin,
   122→      rateMax,
   123→      rateType: generated.rateType || "hourly",
   124→    });
   125→  } catch (error) {
   126→    logger.error("AI job generation error:", error);
   127→
   128→    if (error instanceof Error) {
   129→      if (error.message.includes("API key")) {
   130→        return NextResponse.json({ error: "AI service configuration error" }, { status: 503 });
   131→      }
   132→      if (error.message.includes("rate limit") || error.message.includes("429")) {
   133→        return NextResponse.json({ error: "AI service is busy. Please try again." }, { status: 429 });
   134→      }
   135→    }
   136→
   137→    return NextResponse.json({ error: "Failed to generate job details" }, { status: 500 });
   138→  }
   139→};
   140→
   141→export const POST = withCSRFProtection(handler);
   142→

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>


     1→/**
     2→ * Voice Processing Optimization - Consolidated Endpoint
     3→ *
     4→ * This endpoint replaces the previous 3-step sequential process:
     5→ * 1. /api/ai/transcribe-voice (Whisper)
     6→ * 2. /api/ai/parse-profile-voice (GPT-4o-mini parsing)
     7→ * 3. /api/ai/generate-profile (GPT-4o-mini generation)
     8→ *
     9→ * New optimized flow:
    10→ * - Single endpoint handles transcription (if audio provided) + parsing + generation
    11→ * - Reduces network overhead and total processing time by ~30-50%
    12→ * - Provides fallback to manual completion if AI processing fails
    13→ * - Preserves transcript data across failures for better UX
    14→ *
    15→ * Performance improvement: ~5-12 seconds → ~3-7 seconds
    16→ */
    17→
    18→import { NextRequest, NextResponse } from "next/server";
    19→import { logger } from "@/lib/logger";
    20→import OpenAI from "openai";
    21→import { createClient } from "@/lib/supabase/server";
    22→import { checkRateLimit, getClientIP, createRateLimitResponse } from "@/lib/rate-limit";
    23→import { sanitizeInput } from "@/lib/sanitize";
    24→import { withCSRFProtection } from "@/lib/csrf";
    25→import { TRADES } from "@/lib/constants";
    26→import {
    27→  AI_RATE_LIMIT,
    28→  AI_WINDOW_MS,
    29→  MAX_LENGTH_TRADE,
    30→  MAX_LENGTH_LOCATION,
    31→  MAX_LENGTH_AVAILABILITY,
    32→  MAX_LENGTH_VOICE_TRANSCRIPT,
    33→  MAX_LENGTH_TRANSCRIPT
    34→} from "@/lib/constants";
    35→
    36→if (!process.env.OPENAI_API_KEY) {
    37→  throw new Error("OPENAI_API_KEY environment variable is not set");
    38→}
    39→
    40→const openai = new OpenAI({
    41→  apiKey: process.env.OPENAI_API_KEY,
    42→});
    43→
    44→// Rate limit: 5 requests per 15 minutes per IP (expensive AI call)
    45→const VALID_AVAILABILITY = ["full-time", "casual", "weekends", "flexible"];
    46→
    47→const handler = async (request: NextRequest) => {
    48→  try {
    49→    // --- Rate limiting ---
    50→    const clientIP = getClientIP(request);
    51→    const rateLimit = checkRateLimit(`ai-voice-complete:${clientIP}`, AI_RATE_LIMIT, AI_WINDOW_MS);
    52→
    53→    if (!rateLimit.allowed) {
    54→      const retryAfter = Math.ceil((rateLimit.resetTime - Date.now()) / 1000);
    55→      return createRateLimitResponse(clientIP, retryAfter);
    56→    }
    57→
    58→    // --- Auth check ---
    59→    const supabase = await createClient();
    60→    const { data: { user }, error: authError } = await supabase.auth.getUser();
    61→    if (authError || !user) {
    62→      return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
    63→    }
    64→
    65→    // Parse request based on content type
    66→    let audioFile: File | null = null;
    67→    let existingTranscript: string | null = null;
    68→
    69→    const contentType = request.headers.get("content-type") || "";
    70→
    71→    if (contentType.includes("multipart/form-data")) {
    72→      // Handle audio file upload
    73→      const formData = await request.formData();
    74→      audioFile = formData.get("audio") as File | null;
    75→      existingTranscript = formData.get("transcript") as string | null;
    76→
    77→      if (!audioFile && !existingTranscript) {
    78→        return NextResponse.json(
    79→          { error: "No audio file or transcript provided" },
    80→          { status: 400 }
    81→        );
    82→      }
    83→    } else {
    84→      // Handle JSON with existing transcript
    85→      const body = await request.json();
    86→      existingTranscript = body.transcript;
    87→
    88→      if (!existingTranscript) {
    89→        return NextResponse.json(
    90→          { error: "No transcript provided" },
    91→          { status: 400 }
    92→        );
    93→      }
    94→    }
    95→
    96→    let transcript: string;
    97→
    98→    // Step 1: Transcribe audio if needed
    99→    if (audioFile) {
   100→      try {
   101→        const transcription = await openai.audio.transcriptions.create({
   102→          file: audioFile,
   103→          model: "whisper-1",
   104→          language: "en",
   105→        });
   106→        transcript = transcription.text;
   107→      } catch (error) {
   108→        logger.error("OpenAI transcription error:", error);
   109→        return NextResponse.json(
   110→          { error: "Failed to transcribe audio" },
   111→          { status: 500 }
   112→        );
   113→      }
   114→    } else {
   115→      transcript = existingTranscript!;
   116→    }
   117→
   118→    if (!transcript.trim()) {
   119→      return NextResponse.json(
   120→        { error: "No speech detected in audio" },
   121→        { status: 400 }
   122→      );
   123→    }
   124→
   125→    // Sanitize transcript
   126→    const sanitizedTranscript = sanitizeInput(transcript, { maxLength: MAX_LENGTH_TRANSCRIPT });
   127→
   128→    // Step 2: Parse profile and generate complete profile in single GPT call
   129→    try {
   130→      const completion = await openai.chat.completions.create({
   131→        model: "gpt-4o-mini",
   132→        messages: [
   133→          {
   134→            role: "system",
   135→            content: `You are a profile parser and generator for an Australian construction hiring app.
   136→
   137→First, extract structured data from the worker's spoken description, then generate a complete profile.
   138→
   139→Valid trades: ${TRADES.join(", ")}
   140→Valid availability: ${VALID_AVAILABILITY.join(", ")}
   141→
   142→Return ONLY valid JSON in this exact format:
   143→{
   144→  "parsed_data": {
   145→    "trades": ["array of valid trades mentioned, or empty array"],
   146→    "trade": "primary trade (first one mentioned) or null",
   147→    "experience_years": number or null,
   148→    "location": "suburb/area string or null",
   149→    "availability": "one of the valid availability options or null",
   150→    "additional_details": "any other details mentioned — specific skills, years per trade, preferences, work history, specialties. Capture everything not covered by the structured fields above. null if nothing extra."
   151→  },
   152→  "generated_profile": {
   153→    "bio": "professional 2-3 sentence bio (50-80 words) written in FIRST PERSON",
   154→    "skills": ["array of 6-10 relevant technical skills"],
   155→    "certifications": ["array of 3-5 common/recommended certifications"]
   156→  }
   157→}
   158→
   159→Match trades loosely — e.g. "sparky" = "Electrician", "chippie" = "Carpenter", "bricky" = "Bricklayer", "steel fixer"/"steelfixer"/"reo" = "Steel Fixer", "formworker"/"formwork"/"shuttering"/"shutter hand" = "Formworker".
   160→IMPORTANT: Extract ALL trades mentioned, not just one. If someone says "steel fixing and shuttering", return both "Steel Fixer" and "Formworker".
   161→For experience, extract the number of years mentioned (use the highest if multiple are given for different trades).
   162→For location, extract any suburb, city, or area name mentioned.
   163→For availability, match to the closest option.
   164→
   165→For the bio: Make it sound professional but approachable. Focus on practical skills and experience. Write in FIRST PERSON ("I", "my", "me"). Incorporate specific details from the transcript to make it personal and unique. If multiple trades are mentioned, reference them appropriately.`,
   166→          },
   167→          {
   168→            role: "user",
   169→            content: sanitizedTranscript,
   170→          },
   171→        ],
   172→        temperature: 0.3,
   173→        response_format: { type: "json_object" },
   174→      });
   175→
   176→      const content = completion.choices[0]?.message?.content;
   177→      if (!content) {
   178→        throw new Error("No response from AI");
   179→      }
   180→
   181→      const result = JSON.parse(content);
   182→
   183→      // Validate and clean parsed data
   184→      const parsedData = result.parsed_data || {};
   185→      const generatedProfile = result.generated_profile || {};
   186→
   187→      // Validate trades array
   188→      if (Array.isArray(parsedData.trades)) {
   189→        parsedData.trades = parsedData.trades.filter((t: string) => (TRADES as readonly string[]).includes(t));
   190→      } else {
   191→        parsedData.trades = [];
   192→      }
   193→
   194→      // Validate primary trade
   195→      if (parsedData.trade && !(TRADES as readonly string[]).includes(parsedData.trade)) {
   196→        parsedData.trade = parsedData.trades[0] || null;
   197→      } else if (!parsedData.trade && parsedData.trades.length > 0) {
   198→        parsedData.trade = parsedData.trades[0];
   199→      }
   200→
   201→      // Validate availability
   202→      if (parsedData.availability && !VALID_AVAILABILITY.includes(parsedData.availability)) {
   203→        parsedData.availability = null;
   204→      }
   205→
   206→      // Validate experience_years
   207→      if (parsedData.experience_years !== null && (typeof parsedData.experience_years !== "number" || parsedData.experience_years < 0)) {
   208→        parsedData.experience_years = null;
   209→      }
   210→
   211→      return NextResponse.json({
   212→        transcript: sanitizedTranscript,
   213→        parsed_data: parsedData,
   214→        generated_profile: {
   215→          bio: generatedProfile.bio || "",
   216→          skills: generatedProfile.skills || [],
   217→          certifications: generatedProfile.certifications || [],
   218→        },
   219→        // Include timing for performance monitoring
   220→        timing: {
   221→          had_audio: !!audioFile,
   222→          processing_complete: true,
   223→        }
   224→      });
   225→
   226→    } catch (error) {
   227→      logger.error("OpenAI processing error:", error);
   228→
   229→      // If AI parsing fails, return the transcript so user can complete manually
   230→      return NextResponse.json({
   231→        transcript: sanitizedTranscript,
   232→        parsed_data: {
   233→          trades: [],
   234→          trade: null,
   235→          experience_years: null,
   236→          location: null,
   237→          availability: null,
   238→          additional_details: null,
   239→        },
   240→        generated_profile: {
   241→          bio: "",
   242→          skills: [],
   243→          certifications: [],
   244→        },
   245→        error: "AI processing failed. Please complete your profile manually.",
   246→        fallback: true,
   247→        timing: {
   248→          had_audio: !!audioFile,
   249→          processing_complete: false,
   250→        }
   251→      });
   252→    }
   253→
   254→  } catch (error) {
   255→    logger.error("Voice complete error:", error);
   256→
   257→    if (error instanceof Error) {
   258→      if (error.message.includes("API key")) {
   259→        return NextResponse.json(
   260→          { error: "AI service configuration error" },
   261→          { status: 503 }
   262→        );
   263→      }
   264→      if (error.message.includes("rate limit") || error.message.includes("429")) {
   265→        return NextResponse.json(
   266→          { error: "AI service is busy. Please try again in a moment." },
   267→          { status: 429 }
   268→        );
   269→      }
   270→    }
   271→
   272→    return NextResponse.json(
   273→      { error: "Failed to process voice input. Please try again." },
   274→      { status: 500 }
   275→    );
   276→  }
   277→};
   278→
   279→// Apply CSRF protection to the handler
   280→export const POST = withCSRFProtection(handler);

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>


     1→import { NextRequest, NextResponse } from "next/server";
     2→import { logger } from "@/lib/logger";
     3→import OpenAI from "openai";
     4→import { createClient } from "@/lib/supabase/server";
     5→import { checkRateLimit, getClientIP, createRateLimitResponse } from "@/lib/rate-limit";
     6→import { sanitizeInput } from "@/lib/sanitize";
     7→import { withCSRFProtection } from "@/lib/csrf";
     8→import { TRADES } from "@/lib/constants";
     9→import {
    10→  AI_RATE_LIMIT,
    11→  AI_WINDOW_MS,
    12→  MAX_LENGTH_TRADE,
    13→  MAX_LENGTH_LOCATION,
    14→  MAX_LENGTH_AVAILABILITY,
    15→  MAX_LENGTH_VOICE_TRANSCRIPT,
    16→  HTTP_STATUS_INTERNAL_SERVER_ERROR
    17→} from "@/lib/constants";
    18→
    19→if (!process.env.OPENAI_API_KEY) {
    20→  throw new Error("OPENAI_API_KEY environment variable is not set");
    21→}
    22→
    23→const openai = new OpenAI({
    24→  apiKey: process.env.OPENAI_API_KEY,
    25→});
    26→
    27→// Rate limit: 5 requests per 15 minutes per IP (expensive AI call)
    28→
    29→const handler = async (request: NextRequest) => {
    30→  try {
    31→    // --- Rate limiting ---
    32→    const clientIP = getClientIP(request);
    33→    const rateLimit = checkRateLimit(`ai-profile:${clientIP}`, AI_RATE_LIMIT, AI_WINDOW_MS);
    34→
    35→    if (!rateLimit.allowed) {
    36→      const retryAfter = Math.ceil((rateLimit.resetTime - Date.now()) / 1000);
    37→      return createRateLimitResponse(clientIP, retryAfter);
    38→    }
    39→
    40→    // --- Auth check ---
    41→    const supabase = await createClient();
    42→    const { data: { user }, error: authError } = await supabase.auth.getUser();
    43→    if (authError || !user) {
    44→      return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
    45→    }
    46→
    47→    const { trade, trades, experience, location, availability, voiceTranscript } = await request.json();
    48→
    49→    if (!trade || experience === undefined || !location || !availability) {
    50→      return NextResponse.json(
    51→        { error: "Missing required fields" },
    52→        { status: 400 }
    53→      );
    54→    }
    55→
    56→    // Sanitize inputs to prevent prompt injection
    57→    const sanitizedTrade = sanitizeInput(trade, { maxLength: MAX_LENGTH_TRADE });
    58→    const sanitizedLocation = sanitizeInput(location, { maxLength: MAX_LENGTH_LOCATION });
    59→    const sanitizedAvailability = sanitizeInput(availability, { maxLength: MAX_LENGTH_AVAILABILITY });
    60→    const sanitizedTranscript = voiceTranscript ? sanitizeInput(voiceTranscript, { maxLength: MAX_LENGTH_VOICE_TRANSCRIPT }) : null;
    61→
    62→    // Validate primary trade
    63→    if (!(TRADES as readonly string[]).includes(sanitizedTrade)) {
    64→      return NextResponse.json(
    65→        { error: `Invalid trade. Must be one of: ${TRADES.join(", ")}` },
    66→        { status: 400 }
    67→      );
    68→    }
    69→
    70→    // Sanitize and validate additional trades
    71→    const allTrades: string[] = Array.isArray(trades)
    72→      ? trades
    73→          .map((t: string) => sanitizeInput(t, { maxLength: MAX_LENGTH_TRADE }))
    74→          .filter((t: string) => (TRADES as readonly string[]).includes(t))
    75→      : [sanitizedTrade];
    76→
    77→    // Ensure at least the primary trade is included
    78→    if (allTrades.length === 0) {
    79→      allTrades.push(sanitizedTrade);
    80→    }
    81→
    82→    const tradesStr = allTrades.join(", ");
    83→
    84→    // Validate experience is a number
    85→    const experienceYears = parseInt(experience, 10);
    86→    if (isNaN(experienceYears) || experienceYears < 0 || experienceYears > 100) {
    87→      return NextResponse.json(
    88→        { error: "Invalid experience value" },
    89→        { status: 400 }
    90→      );
    91→    }
    92→
    93→    const transcriptSection = sanitizedTranscript
    94→      ? `\n\nOriginal voice description from the worker (use this to make the bio personal and unique — reference specific details they mentioned):\n"${sanitizedTranscript}"`
    95→      : "";
    96→
    97→    const prompt = `You are an expert construction industry recruiter writing a profile for an Australian construction worker.
    98→
    99→Worker Information:
   100→- Trade(s): ${tradesStr}
   101→- Years of Experience: ${experienceYears} years
   102→- Location: ${sanitizedLocation}
   103→- Availability: ${sanitizedAvailability}${transcriptSection}
   104→
   105→Generate a JSON response with:
   106→1. bio: A professional 2-3 sentence bio (50-80 words) written in FIRST PERSON ("I", "my", "me"). It's the worker's own bio on their profile. Make it unique — if voice details are provided, incorporate specific things they mentioned (multiple trades, specialties, work style). Do NOT use third person ("they", "their", "them").
   107→2. skills: An array of 6-10 relevant technical skills for their trade(s) (specific tools, techniques, materials). If multiple trades, include skills from each.
   108→3. certifications: An array of 3-5 common/recommended certifications for their trade(s) (actual Australian construction certifications)
   109→
   110→Make it sound professional but approachable. Focus on practical skills and experience.
   111→
   112→Return ONLY valid JSON in this exact format:
   113→{
   114→  "bio": "string",
   115→  "skills": ["skill1", "skill2", ...],
   116→  "certifications": ["cert1", "cert2", ...]
   117→}`;
   118→
   119→    const completion = await openai.chat.completions.create({
   120→      model: "gpt-4o-mini",
   121→      messages: [
   122→        {
   123→          role: "system",
   124→          content: "You are a construction industry expert. Always respond with valid JSON only.",
   125→        },
   126→        {
   127→          role: "user",
   128→          content: prompt,
   129→        },
   130→      ],
   131→      temperature: 0.7,
   132→      response_format: { type: "json_object" },
   133→    });
   134→
   135→    const content = completion.choices[0]?.message?.content;
   136→    if (!content) {
   137→      throw new Error("No response from AI");
   138→    }
   139→
   140→    const generated = JSON.parse(content);
   141→
   142→    return NextResponse.json({
   143→      bio: generated.bio || "",
   144→      skills: generated.skills || [],
   145→      certifications: generated.certifications || [],
   146→    });
   147→  } catch (error) {
   148→    logger.error("AI generation error:", error);
   149→
   150→    // Return more specific error messages
   151→    if (error instanceof Error) {
   152→      if (error.message.includes("API key")) {
   153→        return NextResponse.json(
   154→          { error: "AI service configuration error" },
   155→          { status: 503 }
   156→        );
   157→      }
   158→      if (error.message.includes("rate limit") || error.message.includes("429")) {
   159→        return NextResponse.json(
   160→          { error: "AI service is busy. Please try again in a moment." },
   161→          { status: 429 }
   162→        );
   163→      }
   164→    }
   165→
   166→    return NextResponse.json(
   167→      { error: "Failed to generate profile. Please try again." },
   168→      { status: 500 }
   169→    );
   170→  }
   171→}
   172→
   173→// Apply CSRF protection to the handler
   174→export const POST = withCSRFProtection(handler);
   175→

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>


     1→/**
     2→ * Voice Profile Parsing Endpoint
     3→ *
     4→ * DEPRECATED: This endpoint is maintained for backward compatibility.
     5→ * For new implementations, use /api/ai/voice-complete which provides
     6→ * consolidated transcription + parsing + generation for better performance.
     7→ *
     8→ * Performance: Use voice-complete for 30-50% faster processing
     9→ */
    10→
    11→import { NextRequest, NextResponse } from "next/server";
    12→import { logger } from "@/lib/logger";
    13→import OpenAI from "openai";
    14→import { createClient } from "@/lib/supabase/server";
    15→import { checkRateLimit, getClientIP, createRateLimitResponse } from "@/lib/rate-limit";
    16→import { withErrorHandler, APIError, ErrorCodes } from "@/lib/api-errors";
    17→import { sanitizeInput } from "@/lib/sanitize";
    18→import { validateCSRFToken, isSameOrigin } from "@/lib/csrf";
    19→import { TRADES } from "@/lib/constants";
    20→import {
    21→  AI_RATE_LIMIT,
    22→  RATE_LIMIT_WINDOW_MS as AI_WINDOW_MS,
    23→  MAX_LENGTH_TRANSCRIPT
    24→} from "@/lib/constants";
    25→
    26→// Cast helper to use NextRequest methods while satisfying withErrorHandler signature
    27→type Handler = (request: Request) => Promise<Response>;
    28→
    29→if (!process.env.OPENAI_API_KEY) {
    30→  throw new Error("OPENAI_API_KEY environment variable is not set");
    31→}
    32→
    33→const openai = new OpenAI({
    34→  apiKey: process.env.OPENAI_API_KEY,
    35→});
    36→
    37→// Rate limit: 10 requests per 15 minutes per user
    38→
    39→const VALID_AVAILABILITY = ["full-time", "casual", "weekends", "flexible"];
    40→
    41→async function handler(request: Request) {
    42→  const nextRequest = request as NextRequest;
    43→  // CSRF protection: validate same-origin and token
    44→  if (!isSameOrigin(request)) {
    45→    return NextResponse.json({ error: 'Cross-origin request not allowed' }, { status: 403 });
    46→  }
    47→  const csrfValid = await validateCSRFToken(request);
    48→  if (!csrfValid) {
    49→    return NextResponse.json({ error: 'Invalid or missing CSRF token' }, { status: 403 });
    50→  }
    51→
    52→  // Auth check
    53→  const supabase = await createClient();
    54→  const { data: { user } } = await supabase.auth.getUser();
    55→  if (!user) {
    56→    throw new APIError("Unauthorized", 401, ErrorCodes.UNAUTHORIZED);
    57→  }
    58→
    59→  // Rate limit
    60→  const clientIP = getClientIP(request);
    61→  const rateLimit = checkRateLimit(`parse-voice:${clientIP}`, AI_RATE_LIMIT, AI_WINDOW_MS);
    62→  if (!rateLimit.allowed) {
    63→    const retryAfter = Math.ceil((rateLimit.resetTime - Date.now()) / 1000);
    64→    return createRateLimitResponse(clientIP, retryAfter);
    65→  }
    66→
    67→  const body = await request.json();
    68→  const { transcript } = body;
    69→
    70→  if (!transcript || typeof transcript !== "string") {
    71→    throw new APIError("Missing transcript", 400, ErrorCodes.INVALID_INPUT);
    72→  }
    73→
    74→  // Sanitize transcript to prevent prompt injection
    75→  const sanitizedTranscript = sanitizeInput(transcript, { maxLength: MAX_LENGTH_TRANSCRIPT });
    76→
    77→  try {
    78→    const completion = await openai.chat.completions.create({
    79→      model: "gpt-4o-mini",
    80→      messages: [
    81→        {
    82→          role: "system",
    83→          content: `You are a profile parser for an Australian construction hiring app. Extract structured data from a worker's spoken description.
    84→
    85→Valid trades: ${TRADES.join(", ")}
    86→Valid availability: ${VALID_AVAILABILITY.join(", ")}
    87→
    88→Return ONLY valid JSON in this exact format:
    89→{
    90→  "trades": ["array of valid trades mentioned, or empty array"],
    91→  "trade": "primary trade (first one mentioned) or null",
    92→  "experience_years": number or null,
    93→  "location": "suburb/area string or null",
    94→  "availability": "one of the valid availability options or null",
    95→  "additional_details": "any other details mentioned — specific skills, years per trade, preferences, work history, specialties. Capture everything not covered by the structured fields above. null if nothing extra."
    96→}
    97→
    98→Match trades loosely — e.g. "sparky" = "Electrician", "chippie" = "Carpenter", "bricky" = "Bricklayer", "steel fixer"/"steelfixer"/"reo" = "Steel Fixer", "formworker"/"formwork"/"shuttering"/"shutter hand" = "Formworker".
    99→IMPORTANT: Extract ALL trades mentioned, not just one. If someone says "steel fixing and shuttering", return both "Steel Fixer" and "Formworker".
   100→For experience, extract the number of years mentioned (use the highest if multiple are given for different trades).
   101→For location, extract any suburb, city, or area name mentioned.
   102→For availability, match to the closest option.
   103→If a field is not mentioned, set it to null (or empty array for trades).`,
   104→        },
   105→        {
   106→          role: "user",
   107→          content: sanitizedTranscript,
   108→        },
   109→      ],
   110→      temperature: 0.3,
   111→      response_format: { type: "json_object" },
   112→    });
   113→
   114→    const content = completion.choices[0]?.message?.content;
   115→    if (!content) {
   116→      throw new APIError("No response from AI", 500, ErrorCodes.EXTERNAL_SERVICE_ERROR);
   117→    }
   118→
   119→    const parsed = JSON.parse(content);
   120→
   121→    // Validate trades array
   122→    if (Array.isArray(parsed.trades)) {
   123→      parsed.trades = parsed.trades.filter((t: string) => (TRADES as readonly string[]).includes(t));
   124→    } else {
   125→      parsed.trades = [];
   126→    }
   127→
   128→    // Validate primary trade (use first from trades array if invalid)
   129→    if (parsed.trade && !(TRADES as readonly string[]).includes(parsed.trade)) {
   130→      parsed.trade = parsed.trades[0] || null;
   131→    } else if (!parsed.trade && parsed.trades.length > 0) {
   132→      parsed.trade = parsed.trades[0];
   133→    }
   134→
   135→    // Validate availability
   136→    if (parsed.availability && !VALID_AVAILABILITY.includes(parsed.availability)) {
   137→      parsed.availability = null;
   138→    }
   139→
   140→    // Validate experience_years
   141→    if (parsed.experience_years !== null && (typeof parsed.experience_years !== "number" || parsed.experience_years < 0)) {
   142→      parsed.experience_years = null;
   143→    }
   144→
   145→    return Response.json(parsed);
   146→  } catch (error) {
   147→    logger.error("OpenAI parsing error:", error);
   148→    if (error instanceof APIError) {
   149→      throw error;
   150→    }
   151→    throw new APIError(
   152→      "Failed to parse voice input",
   153→      500,
   154→      ErrorCodes.EXTERNAL_SERVICE_ERROR
   155→    );
   156→  }
   157→}
   158→
   159→export const POST = withErrorHandler(handler);
   160→

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>