Search the codebase at /home/ccuser/the-50-dollar-app for all files related to voice processing, speech recognition, whisper, voice-to-text, or job posting with voice. Look for:
1. Any API routes related to voice/speech/whisper/transcription
2. Frontend components that handle voice input for job posting
3. Any references to "Voice processing failed" toast message
4. Service worker or audio recording code

Search thoroughly and return the file paths and key code snippets.


I'll search thoroughly for all voice processing and speech recognition related files in the codebase.


No files found


Found 1 file
src/app/(authenticated)/contractor/post-job/page.tsx


Great! I found some key files. Let me search for more specific patterns and examine the API routes and components.


Found 6 files
src/app/(authenticated)/contractor/post-job/page.tsx
src/app/(authenticated)/worker/signup/hooks/useVoiceProcessing.ts
src/app/api/ai/transcribe-voice/route.ts
src/app/api/ai/parse-profile-voice/route.ts
src/app/api/ai/voice-complete/route.ts
src/app/(authenticated)/worker/profile/build/page.tsx


Found 7 files
src/app/privacy/page.tsx
src/app/(authenticated)/worker/signup/page.tsx
src/app/(authenticated)/contractor/post-job/page.tsx
src/app/(authenticated)/worker/signup/hooks/useVoiceProcessing.ts
src/app/(authenticated)/worker/profile/build/page.tsx
src/components/VoiceRecorder.tsx
src/app/legal/privacy-policy/page.tsx


Excellent! Now let me read all these key files to get the complete picture.


1→"use client";
     2→
     3→import { useState, useRef, useCallback } from "react";
     4→import { Mic, Square, Loader2 } from "lucide-react";
     5→
     6→interface VoiceRecorderProps {
     7→  onRecordingComplete: (blob: Blob) => void;
     8→  disabled?: boolean;
     9→}
    10→
    11→export default function VoiceRecorder({ onRecordingComplete, disabled }: VoiceRecorderProps) {
    12→  const [recording, setRecording] = useState(false);
    13→  const [starting, setStarting] = useState(false);
    14→  const [error, setError] = useState<string | null>(null);
    15→  const mediaRecorderRef = useRef<MediaRecorder | null>(null);
    16→  const chunksRef = useRef<Blob[]>([]);
    17→
    18→  const startRecording = useCallback(async () => {
    19→    setError(null);
    20→    setStarting(true);
    21→    try {
    22→      const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
    23→      const mediaRecorder = new MediaRecorder(stream, {
    24→        mimeType: MediaRecorder.isTypeSupported("audio/webm")
    25→          ? "audio/webm"
    26→          : "audio/mp4",
    27→      });
    28→      mediaRecorderRef.current = mediaRecorder;
    29→      chunksRef.current = [];
    30→
    31→      mediaRecorder.ondataavailable = (e) => {
    32→        if (e.data.size > 0) {
    33→          chunksRef.current.push(e.data);
    34→        }
    35→      };
    36→
    37→      mediaRecorder.onstop = () => {
    38→        const blob = new Blob(chunksRef.current, { type: mediaRecorder.mimeType });
    39→        onRecordingComplete(blob);
    40→        stream.getTracks().forEach((track) => track.stop());
    41→      };
    42→
    43→      mediaRecorder.start();
    44→      setRecording(true);
    45→    } catch (err) {
    46→      if (err instanceof DOMException) {
    47→        switch (err.name) {
    48→          case "NotAllowedError":
    49→            setError("Microphone access denied. Please allow microphone access in your browser settings.");
    50→            break;
    51→          case "NotFoundError":
    52→            setError("No microphone found. Please connect a microphone and try again.");
    53→            break;
    54→          case "AbortError":
    55→            // User cancelled, no need to show error
    56→            break;
    57→          case "SecurityError":
    58→            setError("Microphone access requires a secure connection (HTTPS).");
    59→            break;
    60→          default:
    61→            setError("Could not access microphone. Please try again.");
    62→        }
    63→      } else {
    64→        setError("Could not access microphone. Please try again.");
    65→      }
    66→    } finally {
    67→      setStarting(false);
    68→    }
    69→  }, [onRecordingComplete]);
    70→
    71→  const stopRecording = useCallback(() => {
    72→    if (mediaRecorderRef.current && mediaRecorderRef.current.state === "recording") {
    73→      mediaRecorderRef.current.stop();
    74→      setRecording(false);
    75→    }
    76→  }, []);
    77→
    78→  return (
    79→

    80→      <button
    81→        type="button"
    82→        onClick={recording ? stopRecording : startRecording}
    83→        disabled={disabled || starting}
    84→        className={`flex h-16 w-16 items-center justify-center rounded-full transition-all active:scale-95 ${
    85→          recording
    86→            ? "animate-pulse bg-red-500 text-white shadow-lg shadow-red-500/40"
    87→            : "bg-primary text-primary-foreground shadow-md hover:shadow-lg"
    88→        } ${disabled || starting ? "opacity-50 cursor-not-allowed" : ""}`}
    89→        aria-label={recording ? "Stop recording" : "Start recording"}
    90→      >
    91→        {starting ? (
    92→          <Loader2 className="h-7 w-7 animate-spin" />
    93→        ) : recording ? (
    94→          <Square className="h-7 w-7" />
    95→        ) : (
    96→          <Mic className="h-7 w-7" />
    97→        )}
    98→      </button>
    99→

   100→        {starting ? "Starting..." : recording ? "Tap to stop" : "Tap to speak"}
   101→

   102→      {error && (
   103→
{error}

   104→      )}
   105→

   106→  );
   107→}
   108→

<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→"use client";
     2→
     3→import { useState } from "react";
     4→import toast from "react-hot-toast";
     5→import { csrfFetch, addCSRFTokenToFormData, getCSRFCookie, initializeCSRF } from "@/lib/csrf-client";
     6→import { TRADES } from "@/lib/constants";
     7→import type { Step, WorkerData } from "../types";
     8→import { EXPERIENCE_OPTIONS } from "../types";
     9→
    10→interface VoiceProcessingState {
    11→  processing: boolean;
    12→  status: string;
    13→  error: string | null;
    14→  transcript: string | null;
    15→}
    16→
    17→export function useVoiceProcessing(
    18→  setSelectedTrades: (trades: string[]) => void,
    19→  setExperience: (exp: number) => void,
    20→  setSuburb: (suburb: string) => void,
    21→  setAvailability: (availability: WorkerData["availability"]) => void,
    22→  setStep: (step: Step) => void
    23→) {
    24→  const [voiceState, setVoiceState] = useState<VoiceProcessingState>({
    25→    processing: false,
    26→    status: "",
    27→    error: null,
    28→    transcript: null,
    29→  });
    30→
    31→  async function handleVoiceComplete(blob: Blob) {
    32→    setVoiceState({
    33→      processing: true,
    34→      status: "Transcribing your voice...",
    35→      error: null,
    36→      transcript: null,
    37→    });
    38→
    39→    try {
    40→      // Ensure CSRF token exists before making requests
    41→      if (!getCSRFCookie()) {
    42→        await initializeCSRF();
    43→      }
    44→
    45→      // Step 1: Transcribe (FormData with CSRF token)
    46→      const formData = new FormData();
    47→      formData.append("audio", blob, "recording.webm");
    48→      addCSRFTokenToFormData(formData);
    49→
    50→      const transcribeRes = await csrfFetch("/api/ai/transcribe-voice", {
    51→        method: "POST",
    52→        body: formData,
    53→      });
    54→
    55→      if (!transcribeRes.ok) {
    56→        if (transcribeRes.status === 429) {
    57→          throw new Error("Too many requests. Please wait a few minutes.");
    58→        }
    59→        const errData = await transcribeRes.json().catch(() => ({}));
    60→        throw new Error(errData.error || "Failed to transcribe audio");
    61→      }
    62→
    63→      const { text } = await transcribeRes.json();
    64→      if (!text) throw new Error("No speech detected. Please try again.");
    65→
    66→      // Store the raw transcript for the bio generator
    67→      setVoiceState(prev => ({ ...prev, transcript: text, status: "Understanding your details..." }));
    68→
    69→      // Step 2: Parse profile fields (JSON with CSRF header)
    70→      const parseRes = await csrfFetch("/api/ai/parse-profile-voice", {
    71→        method: "POST",
    72→        headers: { "Content-Type": "application/json" },
    73→        body: JSON.stringify({ transcript: text }),
    74→      });
    75→
    76→      if (!parseRes.ok) {
    77→        const errData = await parseRes.json().catch(() => ({}));
    78→        throw new Error(errData.error || "Failed to parse your details");
    79→      }
    80→
    81→      const parsed = await parseRes.json();
    82→
    83→      // Pre-fill fields — handle multiple trades
    84→      if (parsed.trades && parsed.trades.length > 0) {
    85→        setSelectedTrades(parsed.trades);
    86→      } else if (parsed.trade) {
    87→        setSelectedTrades([parsed.trade]);
    88→      }
    89→      if (parsed.experience_years !== null && parsed.experience_years !== undefined) {
    90→        // Find closest experience option
    91→        const closest = EXPERIENCE_OPTIONS.reduce((prev, curr) =>
    92→          Math.abs(curr.value - parsed.experience_years) < Math.abs(prev.value - parsed.experience_years) ? curr : prev
    93→        );
    94→        setExperience(closest.value);
    95→      }
    96→      if (parsed.location) setSuburb(parsed.location);
    97→      if (parsed.availability) setAvailability(parsed.availability);
    98→
    99→      // Check what we got and move to the right step
   100→      setVoiceState(prev => ({ ...prev, status: "" }));
   101→
   102→      const hasTrade = parsed.trades && parsed.trades.length > 0;
   103→      const hasExp = parsed.experience_years !== null && parsed.experience_years !== undefined;
   104→      const hasLoc = !!parsed.location;
   105→      const hasAvail = !!parsed.availability;
   106→
   107→      if (hasTrade && hasExp && hasLoc && hasAvail) {
   108→        // All fields filled — go straight to review
   109→        setStep("review");
   110→      } else if (!hasTrade) {
   111→        setStep("trade");
   112→      } else if (!hasExp) {
   113→        setStep("experience");
   114→      } else if (!hasLoc) {
   115→        setStep("location");
   116→      } else {
   117→        setStep("availability");
   118→      }
   119→    } catch (error) {
   120→      setVoiceState(prev => ({
   121→        ...prev,
   122→        error: error instanceof Error ? error.message : "Something went wrong. Please try again.",
   123→        status: "",
   124→      }));
   125→    } finally {
   126→      setVoiceState(prev => ({ ...prev, processing: false }));
   127→    }
   128→  }
   129→
   130→  return {
   131→    voiceState,
   132→    handleVoiceComplete,
   133→  };
   134→}

<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 Transcription 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 { validateCSRFToken, isSameOrigin } from "@/lib/csrf";
    18→import {
    19→  AI_RATE_LIMIT,
    20→  AI_WINDOW_MS
    21→} from "@/lib/constants";
    22→
    23→if (!process.env.OPENAI_API_KEY) {
    24→  throw new Error("OPENAI_API_KEY environment variable is not set");
    25→}
    26→
    27→const openai = new OpenAI({
    28→  apiKey: process.env.OPENAI_API_KEY,
    29→});
    30→
    31→// Rate limit: 10 requests per 15 minutes per user
    32→
    33→async function handler(request: Request) {
    34→  // CSRF protection: validate same-origin and token
    35→  if (!isSameOrigin(request)) {
    36→    return NextResponse.json({ error: 'Cross-origin request not allowed' }, { status: 403 });
    37→  }
    38→  const csrfValid = await validateCSRFToken(request);
    39→  if (!csrfValid) {
    40→    return NextResponse.json({ error: 'Invalid or missing CSRF token' }, { status: 403 });
    41→  }
    42→
    43→  // Auth check
    44→  const supabase = await createClient();
    45→  const { data: { user } } = await supabase.auth.getUser();
    46→  if (!user) {
    47→    throw new APIError("Unauthorized", 401, ErrorCodes.UNAUTHORIZED);
    48→  }
    49→
    50→  // Rate limit
    51→  const clientIP = getClientIP(request);
    52→  const rateLimit = await checkRateLimit(`transcribe:${clientIP}`, AI_RATE_LIMIT, AI_WINDOW_MS);
    53→  if (!rateLimit.allowed) {
    54→    const retryAfter = Math.ceil((rateLimit.resetTime - Date.now()) / 1000);
    55→    return createRateLimitResponse(clientIP, retryAfter);
    56→  }
    57→
    58→  const formData = await request.formData();
    59→  const audioFile = formData.get("audio") as File | null;
    60→
    61→  if (!audioFile) {
    62→    throw new APIError("No audio file provided", 400, ErrorCodes.INVALID_INPUT);
    63→  }
    64→
    65→  try {
    66→    const transcription = await openai.audio.transcriptions.create({
    67→      file: audioFile,
    68→      model: "whisper-1",
    69→      language: "en",
    70→    });
    71→
    72→    return Response.json({ text: transcription.text });
    73→  } catch (error) {
    74→    logger.error("OpenAI transcription error:", error);
    75→    throw new APIError(
    76→      "Failed to transcribe audio",
    77→      500,
    78→      ErrorCodes.EXTERNAL_SERVICE_ERROR
    79→    );
    80→  }
    81→}
    82→
    83→export const POST = withErrorHandler(handler);
    84→

<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 = await 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>


     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 = await 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→"use client";
     2→
     3→import { useState, useRef, useCallback, useEffect } from "react";
     4→import { useRouter } from "next/navigation";
     5→import { logger } from "@/lib/logger";
     6→import Link from "next/link";
     7→import toast from "react-hot-toast";
     8→import { createClient } from "@/lib/supabase/client";
     9→import { Button } from "@/components/ui/button";
    10→import { Card, CardContent } from "@/components/ui/card";
    11→import { Input } from "@/components/ui/input";
    12→import { Label } from "@/components/ui/label";
    13→import { Badge } from "@/components/ui/badge";
    14→import {
    15→  ArrowLeft,
    16→  ArrowRight,
    17→  Loader2,
    18→  Sparkles,
    19→  Check,
    20→  MapPin,
    21→  Calendar,
    22→  Users,
    23→  DollarSign,
    24→  HardHat,
    25→  Wrench,
    26→  Hammer,
    27→  Blocks,
    28→  Fence,
    29→  CircleDot,
    30→  Shovel,
    31→  Mic,
    32→  Square,
    33→} from "lucide-react";
    34→import { jsonToPostgresPoint } from "@/lib/location-utils";
    35→import { csrfFetch, withCSRFToken, getCSRFCookie, initializeCSRF, addCSRFTokenToFormData } from "@/lib/csrf-client";
    36→import { stripContactInfo } from "@/lib/sanitize";
    37→
    38→const TRADE_CATEGORIES = [
    39→  { id: "steel-fixer", label: "Steel Fixer", icon: Wrench, color: "bg-blue-500/10 border-blue-500/30 text-blue-700" },
    40→  { id: "formworker", label: "Formworker", icon: Blocks, color: "bg-amber-500/10 border-amber-500/30 text-amber-700" },
    41→  { id: "labourer", label: "Labourer", icon: Shovel, color: "bg-green-500/10 border-green-500/30 text-green-700" },
    42→  { id: "concreter", label: "Concreter", icon: CircleDot, color: "bg-gray-500/10 border-gray-500/30 text-gray-700" },
    43→  { id: "carpenter", label: "Carpenter", icon: Hammer, color: "bg-orange-500/10 border-orange-500/30 text-orange-700" },
    44→  { id: "scaffolder", label: "Scaffolder", icon: Fence, color: "bg-purple-500/10 border-purple-500/30 text-purple-700" },
    45→  { id: "other", label: "Other", icon: HardHat, color: "bg-muted border-border text-foreground" },
    46→];
    47→
    48→const AI_SUGGESTIONS: Record<string, { title: string; description: string; certs: string[]; rateMin: number; rateMax: number; rateType: "hourly" | "daily" }> = {
    49→  "steel-fixer": {
    50→    title: "Steel Fixer Needed",
    51→    description: "Experienced steel fixer required for commercial construction project. Must be able to read drawings, tie rebar, and work with mesh. PPE required on site at all times. Early starts — 6:30am.",
    52→    certs: ["White Card", "Working at Heights"],
    53→    rateMin: 45,
    54→    rateMax: 65,
    55→    rateType: "hourly",
    56→  },
    57→  "formworker": {
    58→    title: "Formworker Required",
    59→    description: "Skilled formworker needed for concrete pours on a residential build. Experience with timber and steel formwork systems. Must be comfortable with early starts and physical work.",
    60→    certs: ["White Card"],
    61→    rateMin: 42,
    62→    rateMax: 58,
    63→    rateType: "hourly",
    64→  },
    65→  "labourer": {
    66→    title: "General Labourer Needed",
    67→    description: "Reliable labourer needed for site clean-up, material handling, and general duties. No specific trade experience needed but must be fit and punctual. PPE and boots required.",
    68→    certs: ["White Card"],
    69→    rateMin: 32,
    70→    rateMax: 42,
    71→    rateType: "hourly",
    72→  },
    73→  "concreter": {
    74→    title: "Concreter Required",
    75→    description: "Experienced concreter needed for slab pours and finishing work. Must know how to screed, float, and finish concrete to a high standard. Early morning pours expected.",
    76→    certs: ["White Card"],
    77→    rateMin: 45,
    78→    rateMax: 65,
    79→    rateType: "hourly",
    80→  },
    81→  "carpenter": {
    82→    title: "Carpenter Needed",
    83→    description: "Qualified carpenter required for framing and fit-out work. Must have own basic tools. Experience with residential or commercial builds preferred.",
    84→    certs: ["White Card", "Trade Certificate"],
    85→    rateMin: 45,
    86→    rateMax: 65,
    87→    rateType: "hourly",
    88→  },
    89→  "scaffolder": {
    90→    title: "Scaffolder Required",
    91→    description: "Licensed scaffolder needed for erection and dismantling of scaffolding systems. Must hold current scaffolding licence. Safety-first attitude essential.",
    92→    certs: ["White Card", "Scaffolding Licence", "Working at Heights"],
    93→    rateMin: 48,
    94→    rateMax: 68,
    95→    rateType: "hourly",
    96→  },
    97→  "other": {
    98→    title: "Construction Worker Needed",
    99→    description: "Worker needed for construction project. Please specify the role and requirements in the description.",
   100→    certs: ["White Card"],
   101→    rateMin: 35,
   102→    rateMax: 55,
   103→    rateType: "hourly",
   104→  },
   105→};
   106→
   107→export default function PostJobPage() {
   108→  const router = useRouter();
   109→  const supabase = createClient();
   110→
   111→  // Ensure CSRF token is available (defensive — may have been missed during signup)
   112→  useEffect(() => {
   113→    if (!getCSRFCookie()) {
   114→      initializeCSRF();
   115→    }
   116→  }, []);
   117→
   118→  const [step, setStep] = useState(1);
   119→  const [loading, setLoading] = useState(false);
   120→  const [aiLoading, setAiLoading] = useState(false);
   121→  const [aiLoadingMessage, setAiLoadingMessage] = useState("");
   122→
   123→  // Voice recording state
   124→  const [voiceRecording, setVoiceRecording] = useState(false);
   125→  const [voiceStarting, setVoiceStarting] = useState(false);
   126→  const [voiceError, setVoiceError] = useState<string | null>(null);
   127→  const mediaRecorderRef = useRef<MediaRecorder | null>(null);
   128→  const chunksRef = useRef<Blob[]>([]);
   129→
   130→  // Step 1: Trade
   131→  const [selectedTrade, setSelectedTrade] = useState("");
   132→
   133→  // Step 2: AI-filled job details + location/date/workers (COMBINED)
   134→  const [title, setTitle] = useState("");
   135→  const [description, setDescription] = useState("");
   136→  const [certs, setCerts] = useState<string[]>([]);
   137→  const [rateMin, setRateMin] = useState("");
   138→  const [rateMax, setRateMax] = useState("");
   139→  const [rateType, setRateType] = useState<"hourly" | "daily">("hourly");
   140→  const [suburb, setSuburb] = useState("");
   141→  const [siteAddress, setSiteAddress] = useState("");
   142→  const [startDate, setStartDate] = useState("");
   143→  const [duration, setDuration] = useState("");
   144→  const [workersNeeded, setWorkersNeeded] = useState("1");
   145→
   146→  // Voice recording handlers
   147→  const startVoiceRecording = useCallback(async () => {
   148→    setVoiceError(null);
   149→    setVoiceStarting(true);
   150→    try {
   151→      const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
   152→      const mediaRecorder = new MediaRecorder(stream, {
   153→        mimeType: MediaRecorder.isTypeSupported("audio/webm")
   154→          ? "audio/webm"
   155→          : "audio/mp4",
   156→      });
   157→      mediaRecorderRef.current = mediaRecorder;
   158→      chunksRef.current = [];
   159→
   160→      mediaRecorder.ondataavailable = (e) => {
   161→        if (e.data.size > 0) {
   162→          chunksRef.current.push(e.data);
   163→        }
   164→      };
   165→
   166→      mediaRecorder.onstop = () => {
   167→        const blob = new Blob(chunksRef.current, { type: mediaRecorder.mimeType });
   168→        stream.getTracks().forEach((track) => track.stop());
   169→        handleVoiceBlob(blob);
   170→      };
   171→
   172→      mediaRecorder.start();
   173→      setVoiceRecording(true);
   174→    } catch (err) {
   175→      if (err instanceof DOMException) {
   176→        switch (err.name) {
   177→          case "NotAllowedError":
   178→            setVoiceError("Microphone access denied. Please allow microphone access in your browser settings.");
   179→            break;
   180→          case "NotFoundError":
   181→            setVoiceError("No microphone found. Please connect a microphone and try again.");
   182→            break;
   183→          case "AbortError":
   184→            break;
   185→          case "SecurityError":
   186→            setVoiceError("Microphone access requires a secure connection (HTTPS).");
   187→            break;
   188→          default:
   189→            setVoiceError("Could not access microphone. Please try again.");
   190→        }
   191→      } else {
   192→        setVoiceError("Could not access microphone. Please try again.");
   193→      }
   194→    } finally {
   195→      setVoiceStarting(false);
   196→    }
   197→  }, []);
   198→
   199→  const stopVoiceRecording = useCallback(() => {
   200→    if (mediaRecorderRef.current && mediaRecorderRef.current.state === "recording") {
   201→      mediaRecorderRef.current.stop();
   202→      setVoiceRecording(false);
   203→    }
   204→  }, []);
   205→
   206→  async function handleVoiceBlob(blob: Blob) {
   207→    setAiLoading(true);
   208→    setAiLoadingMessage("Listening to your voice...");
   209→    setStep(2);
   210→
   211→    try {
   212→      const formData = new FormData();
   213→      formData.append("audio", blob, "recording.webm");
   214→      addCSRFTokenToFormData(formData);
   215→
   216→      setAiLoadingMessage("AI writing your job post...");
   217→
   218→      const res = await fetch("/api/ai/voice-to-job", withCSRFToken({
   219→        method: "POST",
   220→        body: formData,
   221→      }));
   222→
   223→      if (!res.ok) {
   224→        const errData = await res.json().catch(() => ({}));
   225→        throw new Error(errData.error || "Failed to process voice");
   226→      }
   227→
   228→      const data = await res.json();
   229→
   230→      if (data.fallback) {
   231→        toast.error("AI couldn\u2019t fully process your voice. Please fill in the details.");
   232→      }
   233→
   234→      // Auto-fill all form fields from voice
   235→      if (data.trade) setSelectedTrade(data.trade);
   236→      if (data.title) setTitle(data.title);
   237→      if (data.description) setDescription(data.description);
   238→      if (data.certifications) setCerts(data.certifications);
   239→      if (data.rateMin != null) {
   240→        setRateMin(String(data.rateMin));
   241→        // Default max to min unless AI explicitly gave a different max
   242→        setRateMax(String(data.rateMax ?? data.rateMin));
   243→      }
   244→      if (data.rateType) setRateType(data.rateType);
   245→      if (data.suburb) setSuburb(data.suburb);
   246→      if (data.siteAddress) setSiteAddress(data.siteAddress);
   247→      if (data.startDate) setStartDate(data.startDate);
   248→      if (data.duration) setDuration(data.duration);
   249→      if (data.workersNeeded) setWorkersNeeded(String(data.workersNeeded));
   250→    } catch (error) {
   251→      logger.error("Voice-to-job error:", error);
   252→      toast.error("Voice processing failed. Try again or type your job details.");
   253→      setStep(1);
   254→    } finally {
   255→      setAiLoading(false);
   256→      setAiLoadingMessage("");
   257→    }
   258→  }
   259→
   260→  async function handleTradeSelect(tradeId: string) {
   261→    setSelectedTrade(tradeId);
   262→    setAiLoading(true);
   263→    setStep(2);
   264→
   265→    // Try real AI generation, fall back to hardcoded suggestions
   266→    try {
   267→      const res = await csrfFetch("/api/ai/generate-job", {
   268→        method: "POST",
   269→        headers: { "Content-Type": "application/json" },
   270→        body: JSON.stringify({ trade: tradeId }),
   271→      });
   272→
   273→      if (res.ok) {
   274→        const data = await res.json();
   275→        setTitle(data.title);
   276→        setDescription(data.description);
   277→        setCerts(data.certifications);
   278→        setRateMin(String(data.rateMin));
   279→        setRateMax(String(data.rateMax));
   280→        setRateType(data.rateType);
   281→        setAiLoading(false);
   282→        return;
   283→      }
   284→    } catch {
   285→      // Fall through to hardcoded fallback
   286→    }
   287→
   288→    // Fallback: use hardcoded suggestions
   289→    const suggestion = AI_SUGGESTIONS[tradeId];
   290→    if (suggestion) {
   291→      setTitle(suggestion.title);
   292→      setDescription(suggestion.description);
   293→      setCerts(suggestion.certs);
   294→      setRateMin(String(suggestion.rateMin));
   295→      setRateMax(String(suggestion.rateMax));
   296→      setRateType(suggestion.rateType);
   297→    }
   298→    setAiLoading(false);
   299→  }
   300→
   301→  async function handlePost() {
   302→    // Validate required fields
   303→    if (!title || !suburb || !description.trim()) {
   304→      toast.error("Please fill in the title, description, and suburb.");
   305→      return;
   306→    }
   307→
   308→    const rateMinNum = parseFloat(rateMin);
   309→    const rateMaxNum = parseFloat(rateMax);
   310→
   311→    // Validate rates are positive
   312→    if (rateMin && rateMinNum <= 0) {
   313→      toast.error("Minimum rate must be greater than $0.");
   314→      return;
   315→    }
   316→    if (rateMax && rateMaxNum <= 0) {
   317→      toast.error("Maximum rate must be greater than $0.");
   318→      return;
   319→    }
   320→
   321→    // Validate rateMin <= rateMax
   322→    if (rateMin && rateMax && rateMinNum > rateMaxNum) {
   323→      toast.error("Minimum rate cannot be greater than maximum rate.");
   324→      return;
   325→    }
   326→
   327→    setLoading(true);
   328→
   329→    try {
   330→      const { data: { user } } = await supabase.auth.getUser();
   331→      if (!user) {
   332→        toast.error("Session expired. Please log in again.");
   333→        router.push("/auth/login");
   334→        return;
   335→      }
   336→
   337→      // Get contractor's company
   338→      const { data: company } = await supabase
   339→        .from("companies")
   340→        .select("id")
   341→        .eq("profile_id", user.id)
   342→        .single();
   343→
   344→      if (!company) {
   345→        toast.error("Please complete your company profile first.");
   346→        setLoading(false);
   347→        return;
   348→      }
   349→
   350→      // Geocode the suburb to get lat/lng coordinates
   351→      let location: string | null = null;
   352→      if (suburb) {
   353→        try {
   354→          const geocodeRes = await csrfFetch("/api/geocode", {
   355→            method: "POST",
   356→            headers: { "Content-Type": "application/json" },
   357→            body: JSON.stringify({ suburb }),
   358→          });
   359→
   360→          if (geocodeRes.ok) {
   361→            const geocodeData = await geocodeRes.json();
   362→            if (geocodeData.location) {
   363→              location = jsonToPostgresPoint(geocodeData.location);
   364→            }
   365→          }
   366→        } catch (geoError) {
   367→          // Continue without location - suburb text will still be saved
   368→        }
   369→      }
   370→
   371→      // Strip phone numbers and emails to prevent platform bypass
   372→      const sanitizedTitle = stripContactInfo(title);
   373→      const sanitizedDescription = stripContactInfo(description);
   374→
   375→      const { error: insertError } = await supabase.from("jobs").insert({
   376→        company_id: company.id,
   377→        title: sanitizedTitle,
   378→        description: sanitizedDescription,
   379→        trade: selectedTrade,
   380→        suburb,
   381→        location,
   382→        site_address: siteAddress,
   383→        rate_min: parseFloat(rateMin) || null,
   384→        rate_max: parseFloat(rateMax) || parseFloat(rateMin) || null,
   385→        rate_type: rateType,
   386→        start_date: startDate || null,
   387→        duration: duration || null,
   388→        workers_needed: parseInt(workersNeeded) || 1,
   389→        certifications_required: certs,
   390→        status: "active",
   391→        ai_generated: true,
   392→      });
   393→
   394→      if (insertError) {
   395→        toast.error(`Failed to post job: ${insertError.message}`);
   396→        setLoading(false);
   397→        return;
   398→      }
   399→
   400→      // Record first_job_posted milestone (fire-and-forget)
   401→      fetch('/api/success-tracking/record', {
   402→        method: 'POST',
   403→        headers: { 'Content-Type': 'application/json' },
   404→        body: JSON.stringify({ milestone_type: 'first_job_posted' }),
   405→      }).catch(() => {});
   406→
   407→      setStep(3); // Success (was step 5, now step 3)
   408→    } catch (error) {
   409→      logger.error("Job post error:", error);
   410→      toast.error("Something went wrong. Please try again.");
   411→      setLoading(false);
   412→    }
   413→  }
   414→
   415→  return (
   416→

   417→
   418→        {/* STEP 1: Select Trade or Voice */}
   419→        {step === 1 && (
   420→

   421→            {/* Voice Input — Primary CTA */}
   422→

   423→
Post a Job

   424→

   425→                Tap the mic and describe what you need
   426→

   427→
   428→              <button
   429→                type="button"
   430→                onClick={voiceRecording ? stopVoiceRecording : startVoiceRecording}
   431→                disabled={voiceStarting}
   432→                className={`mt-6 flex h-24 w-24 items-center justify-center rounded-full transition-all active:scale-95 ${
   433→                  voiceRecording
   434→                    ? "animate-pulse bg-red-500 text-white shadow-lg shadow-red-500/40"
   435→                    : "bg-primary text-primary-foreground shadow-lg hover:shadow-xl"
   436→                } ${voiceStarting ? "opacity-50 cursor-not-allowed" : ""}`}
   437→                aria-label={voiceRecording ? "Stop recording" : "Start recording"}
   438→              >
   439→                {voiceStarting ? (
   440→                  <Loader2 className="h-10 w-10 animate-spin" />
   441→                ) : voiceRecording ? (
   442→                  <Square className="h-10 w-10" />
   443→                ) : (
   444→                  <Mic className="h-10 w-10" />
   445→                )}
   446→              </button>
   447→
   448→

   449→                {voiceStarting
   450→                  ? "Starting..."
   451→                  : voiceRecording
   452→                    ? "Listening... tap to stop"
   453→                    : "\"I need two steelfixers in Parramatta, 45 an hour\""}
   454→

   455→
   456→              {voiceError && (
   457→
{voiceError}

   458→              )}
   459→

   460→
   461→            {/* Divider */}
   462→

   463→

   464→              or pick a trade
   465→

   466→

   467→
   468→            {/* Trade Selection Grid */}
   469→

   470→              {TRADE_CATEGORIES.map((trade) => {
   471→                const Icon = trade.icon;
   472→                return (
   473→                  <button
   474→                    key={trade.id}
   475→                    onClick={() => handleTradeSelect(trade.id)}
   476→                    className={`flex flex-col items-center justify-center gap-3 rounded-2xl border-2 p-6 min-h-[120px] text-center transition-all active:scale-95 ${trade.color}`}
   477→                  >
   478→                    <Icon className="h-10 w-10" />
   479→                    {trade.label}
   480→                  </button>
   481→                );
   482→              })}
   483→

   484→

   485→        )}
   486→
   487→        {/* STEP 2: AI-filled Job Details + Location/Date/Workers (COMBINED) */}
   488→        {step === 2 && (
   489→

   490→            {aiLoading ? (
   491→

   492→

   493→                  <Sparkles className="h-12 w-12 text-primary animate-pulse" />
   494→

   495→

   496→                  {aiLoadingMessage || "AI writing your job post..."}
   497→

   498→

   499→                  Based on what you said and market rates
   500→

   501→

   502→            ) : (
   503→

   504→

   505→                  <Sparkles className="h-5 w-5 text-primary" />
   506→
Job Details

   507→

   508→

   509→                  AI pre-filled these. Edit anything you like.
   510→

   511→
   512→

   513→                  {/* Job Details Section */}
   514→

   515→                    Job Title
   516→                    <Input
   517→                      id="title"
   518→                      value={title}
   519→                      onChange={(e) => setTitle(e.target.value)}
   520→                      className="mt-2 h-14 text-base"
   521→                    />
   522→

   523→
   524→

   525→                    Description
   526→                    <textarea
   527→                      id="description"
   528→                      value={description}
   529→                      onChange={(e) => setDescription(e.target.value)}
   530→                      rows={5}
   531→                      maxLength={5000}
   532→                      className="mt-2 flex w-full rounded-md border border-input bg-background px-4 py-3 text-base ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
   533→                    />
   534→

   535→
   536→

   537→                    Pay Rate (AUD)
   538→

   539→

   540→                        <DollarSign className="absolute left-4 top-1/2 h-5 w-5 -translate-y-1/2 text-muted-foreground" />
   541→                        <Input
   542→                          type="number"
   543→                          value={rateMin}
   544→                          onChange={(e) => {
   545→                            setRateMin(e.target.value);
   546→                            // Keep max in sync unless user explicitly set a range
   547→                            if (!rateMax || rateMax === rateMin) {
   548→                              setRateMax(e.target.value);
   549→                            }
   550→                          }}
   551→                          placeholder="e.g. 60"
   552→                          className="h-14 pl-10 text-base"
   553→                        />
   554→

   555→                      {rateMin && rateMax && rateMin !== rateMax && (
   556→

   557→                          to
   558→

   559→                            <DollarSign className="absolute left-4 top-1/2 h-5 w-5 -translate-y-1/2 text-muted-foreground" />
   560→                            <Input
   561→                              type="number"
   562→                              value={rateMax}
   563→                              onChange={(e) => setRateMax(e.target.value)}
   564→                              placeholder="Max"
   565→                              className="h-14 pl-10 text-base"
   566→                            />
   567→

   568→                          <Button
   569→                            type="button"
   570→                            variant="ghost"
   571→                            size="sm"
   572→                            onClick={() => setRateMax(rateMin)}
   573→                            className="text-xs text-muted-foreground shrink-0"
   574→                          >
   575→                            Use exact rate
   576→                          </Button>
   577→

   578→                      )}
   579→

   580→

   581→                      <Button
   582→                        type="button"
   583→                        variant={rateType === "hourly" ? "default" : "outline"}
   584→                        onClick={() => setRateType("hourly")}
   585→                        className="h-12 text-base font-semibold"
   586→                      >
   587→                        Per Hour
   588→                      </Button>
   589→                      <Button
   590→                        type="button"
   591→                        variant={rateType === "daily" ? "default" : "outline"}
   592→                        onClick={() => setRateType("daily")}
   593→                        className="h-12 text-base font-semibold"
   594→                      >
   595→                        Per Day
   596→                      </Button>
   597→

   598→

   599→
   600→                  {/* Location Section */}
   601→

   602→

   603→                      <MapPin className="h-5 w-5" />
   604→                      Location
   605→

   606→
   607→

   608→

   609→                        Site Address
   610→                        <Input
   611→                          id="siteAddress"
   612→                          value={siteAddress}
   613→                          onChange={(e) => setSiteAddress(e.target.value)}
   614→                          placeholder="e.g. 123 George St, Sydney"
   615→                          className="mt-2 h-14 text-base"
   616→                        />
   617→

   618→
   619→

   620→                        Suburb
   621→                        <Input
   622→                          id="suburb"
   623→                          value={suburb}
   624→                          onChange={(e) => setSuburb(e.target.value)}
   625→                          placeholder="e.g. Parramatta"
   626→                          className="mt-2 h-14 text-base"
   627→                        />
   628→

   629→

   630→

   631→
   632→                  {/* Date & Workers Section */}
   633→

   634→

   635→                      <Calendar className="h-5 w-5" />
   636→                      Schedule
   637→

   638→
   639→

   640→

   641→                        Start Date
   642→                        <Input
   643→                          id="startDate"
   644→                          type="date"
   645→                          value={startDate}
   646→                          onChange={(e) => setStartDate(e.target.value)}
   647→                          className="mt-2 h-14 text-base"
   648→                        />
   649→

   650→
   651→

   652→                        Duration
   653→                        <Input
   654→                          id="duration"
   655→                          value={duration}
   656→                          onChange={(e) => setDuration(e.target.value)}
   657→                          placeholder="e.g. 2 weeks, ongoing"
   658→                          className="mt-2 h-14 text-base"
   659→                        />
   660→

   661→
   662→

   663→
   664→                          <Users className="h-5 w-5" />
   665→                          Workers Needed
   666→
   667→

   668→                          <Button
   669→                            type="button"
   670→                            variant="outline"
   671→                            onClick={() =>
   672→                              setWorkersNeeded(String(Math.max(1, parseInt(workersNeeded) - 1)))
   673→                            }
   674→                            className="h-14 w-14 text-2xl font-bold"
   675→                          >
   676→                            -
   677→                          </Button>
   678→
   679→                            {workersNeeded}
   680→
   681→                          <Button
   682→                            type="button"
   683→                            variant="outline"
   684→                            onClick={() =>
   685→                              setWorkersNeeded(String(parseInt(workersNeeded) + 1))
   686→                            }
   687→                            className="h-14 w-14 text-2xl font-bold"
   688→                          >
   689→                            +
   690→                          </Button>
   691→

   692→

   693→

   694→

   695→
   696→                  {/* Required Certifications */}
   697→                  {certs.length > 0 && (
   698→

   699→                      Required Certifications
   700→

   701→                        {certs.map((cert) => (
   702→                          <Badge key={cert} variant="secondary" className="text-sm py-1.5 px-3">
   703→                            {cert}
   704→                            <button
   705→                              onClick={() => setCerts(certs.filter((c) => c !== cert))}
   706→                              className="ml-2 text-muted-foreground hover:text-foreground"
   707→                            >
   708→                              ×
   709→                            </button>
   710→                          </Badge>
   711→                        ))}
   712→

   713→

   714→                  )}
   715→

   716→

   717→            )}
   718→

   719→        )}
   720→
   721→        {/* STEP 3: Success */}
   722→        {step === 3 && (
   723→          <Card className="mt-8">
   724→            <CardContent className="flex flex-col items-center py-10 text-center">
   725→

   726→                <Check className="h-10 w-10 text-green-600" />
   727→

   728→
Job Posted!

   729→

   730→                We're matching you with the best workers now.
   731→                You'll get notified when we find matches.
   732→

   733→

   734→                <Button
   735→                  onClick={() => router.push("/contractor/matches")}
   736→                  className="w-full h-12 text-base font-semibold"
   737→                >
   738→                  View Matches
   739→                </Button>
   740→                <Button
   741→                  onClick={() => router.push("/dashboard")}
   742→                  variant="outline"
   743→                  className="w-full h-12 text-base"
   744→                >
   745→                  Back to Dashboard
   746→                </Button>
   747→

   748→            </CardContent>
   749→          </Card>
   750→        )}
   751→
   752→
   753→      {/* Sticky Bottom CTA (Step 2 only) */}
   754→      {step === 2 && !aiLoading && (
   755→

   756→

   757→            <Button
   758→              onClick={handlePost}
   759→              className="w-full h-12 text-base font-semibold"
   760→              disabled={loading || !title || !suburb || !description.trim()}
   761→            >
   762→              {loading ? (
   763→                <>
   764→                  <Loader2 className="mr-2 h-6 w-6 animate-spin" />
   765→                  Posting...
   766→                </>
   767→              ) : (
   768→                <>
   769→                  Post Job Now
   770→                  <Check className="ml-2 h-6 w-6" />
   771→                </>
   772→              )}
   773→            </Button>
   774→

   775→              Free to post. You only pay $50 when you hire someone.
   776→

   777→

   778→

   779→      )}
   780→

   781→  );
   782→}
   783→

<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>


Now let me check for any additional voice-related files and the voice-to-job API route:


     1→"use client";
     2→
     3→import { logger } from "@/lib/logger";
     4→
     5→// Voice Processing Optimization:
     6→// This component now uses the consolidated /api/ai/voice-complete endpoint
     7→// which combines transcription, parsing, and profile generation into a single call.
     8→// This reduces the voice processing time from ~5-12 seconds to ~3-7 seconds.
     9→// Previous flow: Whisper → Parse → Generate (3 sequential calls)
    10→// New flow: [Whisper] + Parse/Generate (1-2 calls, parallel where possible)
    11→
    12→import { useState, useEffect, useCallback } from "react";
    13→import { useRouter } from "next/navigation";
    14→import Link from "next/link";
    15→import toast from "react-hot-toast";
    16→import { createClient } from "@/lib/supabase/client";
    17→import { Button } from "@/components/ui/button";
    18→import { Input } from "@/components/ui/input";
    19→import { Label } from "@/components/ui/label";
    20→import { Card, CardContent } from "@/components/ui/card";
    21→import VoiceRecorder from "@/components/VoiceRecorder";
    22→import { csrfFetch, addCSRFTokenToFormData, initializeCSRF } from "@/lib/csrf-client";
    23→import {
    24→  HardHat,
    25→  ArrowLeft,
    26→  ArrowRight,
    27→  Loader2,
    28→  CheckCircle2,
    29→  Sparkles,
    30→  MapPin,
    31→  Clock,
    32→  Shield,
    33→  Edit3,
    34→  Wrench,
    35→  Calendar,
    36→  Zap,
    37→  Mic,
    38→} from "lucide-react";
    39→import { TRADES } from "@/lib/constants";
    40→
    41→type Step = "trade" | "experience" | "location" | "availability" | "generating" | "review";
    42→
    43→const EXPERIENCE_OPTIONS = [
    44→  { label: "Less than 1 year", value: 0 },
    45→  { label: "1-3 years", value: 2 },
    46→  { label: "3-5 years", value: 4 },
    47→  { label: "5-10 years", value: 7 },
    48→  { label: "10+ years", value: 12 },
    49→];
    50→
    51→const AVAILABILITY_OPTIONS = [
    52→  { label: "Full-time", value: "full-time" as const, desc: "Mon-Fri, regular hours" },
    53→  { label: "Casual", value: "casual" as const, desc: "As needed, flexible" },
    54→  { label: "Weekends", value: "weekends" as const, desc: "Saturday & Sunday" },
    55→  { label: "Flexible", value: "flexible" as const, desc: "Any time, any days" },
    56→];
    57→
    58→interface ProfileData {
    59→  trade: string;
    60→  experience: number;
    61→  location: string;
    62→  availability: "full-time" | "casual" | "weekends" | "flexible";
    63→}
    64→
    65→interface GeneratedProfile {
    66→  bio: string;
    67→  skills: string[];
    68→  certifications: string[];
    69→}
    70→
    71→function closestExperience(years: number): number {
    72→  const values = EXPERIENCE_OPTIONS.map((o) => o.value);
    73→  return values.reduce((prev, curr) =>
    74→    Math.abs(curr - years) < Math.abs(prev - years) ? curr : prev
    75→  );
    76→}
    77→
    78→export default function ProfileBuildPage() {
    79→  const [step, setStep] = useState<Step>("trade");
    80→  const [loading, setLoading] = useState(true);
    81→  const [generating, setGenerating] = useState(false);
    82→  const [saving, setSaving] = useState(false);
    83→
    84→  // Form data
    85→  const [trade, setTrade] = useState<string>("");
    86→  const [experience, setExperience] = useState<number | null>(null);
    87→  const [location, setLocation] = useState("");
    88→  const [availability, setAvailability] = useState<ProfileData["availability"] | null>(null);
    89→
    90→  // Generated data
    91→  const [generatedBio, setGeneratedBio] = useState("");
    92→  const [generatedSkills, setGeneratedSkills] = useState<string[]>([]);
    93→  const [generatedCerts, setGeneratedCerts] = useState<string[]>([]);
    94→
    95→  // Editable states
    96→  const [editedBio, setEditedBio] = useState("");
    97→  const [editedSkills, setEditedSkills] = useState<string[]>([]);
    98→  const [editedCerts, setEditedCerts] = useState<string[]>([]);
    99→
   100→  // Voice flow

<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>


Let me check if the voice-to-job route exists:


Bash

find /home/ccuser/the-50-dollar-app/src/app/api -type f -name "*.ts" -o -name "*.tsx" | grep -i voice


Perfect! Now let me read the voice-to-job route:


     1→/**
     2→ * Voice-to-Job Endpoint
     3→ *
     4→ * Consolidated endpoint for voice-based job posting:
     5→ * 1. Whisper transcribes audio
     6→ * 2. GPT-4o-mini extracts structured job fields from transcript
     7→ *
     8→ * Returns all fields needed to auto-fill the post-job form.
     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 { sanitizeInput, stripContactInfo } from "@/lib/sanitize";
    17→import { withCSRFProtection } from "@/lib/csrf";
    18→import {
    19→  AI_RATE_LIMIT,
    20→  AI_WINDOW_MS,
    21→  MAX_LENGTH_TRANSCRIPT,
    22→} from "@/lib/constants";
    23→
    24→if (!process.env.OPENAI_API_KEY) {
    25→  throw new Error("OPENAI_API_KEY environment variable is not set");
    26→}
    27→
    28→const openai = new OpenAI({
    29→  apiKey: process.env.OPENAI_API_KEY,
    30→});
    31→
    32→const VALID_TRADES = [
    33→  "steel-fixer",
    34→  "formworker",
    35→  "labourer",
    36→  "concreter",
    37→  "carpenter",
    38→  "scaffolder",
    39→  "other",
    40→];
    41→
    42→const handler = async (request: NextRequest) => {
    43→  try {
    44→    const clientIP = getClientIP(request);
    45→    const rateLimit = await checkRateLimit(`ai-voice-job:${clientIP}`, AI_RATE_LIMIT, AI_WINDOW_MS);
    46→
    47→    if (!rateLimit.allowed) {
    48→      const retryAfter = Math.ceil((rateLimit.resetTime - Date.now()) / 1000);
    49→      return createRateLimitResponse(clientIP, retryAfter);
    50→    }
    51→
    52→    const supabase = await createClient();
    53→    const { data: { user }, error: authError } = await supabase.auth.getUser();
    54→    if (authError || !user) {
    55→      return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
    56→    }
    57→
    58→    // Parse multipart form data with audio file
    59→    const contentType = request.headers.get("content-type") || "";
    60→
    61→    let audioFile: File | null = null;
    62→    let existingTranscript: string | null = null;
    63→
    64→    if (contentType.includes("multipart/form-data")) {
    65→      const formData = await request.formData();
    66→      audioFile = formData.get("audio") as File | null;
    67→      existingTranscript = formData.get("transcript") as string | null;
    68→    } else {
    69→      const body = await request.json();
    70→      existingTranscript = body.transcript;
    71→    }
    72→
    73→    if (!audioFile && !existingTranscript) {
    74→      return NextResponse.json(
    75→        { error: "No audio file or transcript provided" },
    76→        { status: 400 }
    77→      );
    78→    }
    79→
    80→    // Step 1: Transcribe audio
    81→    let transcript: string;
    82→
    83→    if (audioFile) {
    84→      try {
    85→        const transcription = await openai.audio.transcriptions.create({
    86→          file: audioFile,
    87→          model: "whisper-1",
    88→          language: "en",
    89→        });
    90→        transcript = transcription.text;
    91→      } catch (error) {
    92→        logger.error("Voice-to-job transcription error:", error);
    93→        return NextResponse.json(
    94→          { error: "Failed to transcribe audio" },
    95→          { status: 500 }
    96→        );
    97→      }
    98→    } else {
    99→      transcript = existingTranscript!;
   100→    }
   101→
   102→    if (!transcript.trim()) {
   103→      return NextResponse.json(
   104→        { error: "No speech detected in audio" },
   105→        { status: 400 }
   106→      );
   107→    }
   108→
   109→    const sanitizedTranscript = sanitizeInput(transcript, { maxLength: MAX_LENGTH_TRANSCRIPT });
   110→
   111→    // Step 2: Extract structured job data from transcript
   112→    try {
   113→      const completion = await openai.chat.completions.create({
   114→        model: "gpt-4o-mini",
   115→        messages: [
   116→          {
   117→            role: "system",
   118→            content: `You are an Australian construction industry expert helping contractors post jobs quickly from voice input.
   119→
   120→Extract structured job posting data from the contractor's spoken description. Use Australian English.
   121→
   122→Valid trade IDs: ${VALID_TRADES.join(", ")}
   123→
   124→Trade matching guide:
   125→- "steelfixer" / "steel fixer" / "reo" / "rebar" → "steel-fixer"
   126→- "formworker" / "formwork" / "shuttering" / "shutter hand" → "formworker"
   127→- "labourer" / "laborer" / "general hand" / "offsider" → "labourer"
   128→- "concreter" / "concrete" / "slab" → "concreter"
   129→- "carpenter" / "chippie" / "chippy" → "carpenter"
   130→- "scaffolder" / "scaffold" → "scaffolder"
   131→- Anything else → "other"
   132→
   133→Return ONLY valid JSON in this exact format:
   134→{
   135→  "trade": "one of the valid trade IDs",
   136→  "title": "concise job title (e.g. 'Steel Fixers Needed for High-Rise')",
   137→  "description": "3-4 sentence professional job description incorporating details from the voice input. Mention key duties, site expectations, PPE requirements. Use Australian English.",
   138→  "certifications": ["array of required certs — always include 'White Card', add others as appropriate"],
   139→  "rateMin": number or null (minimum hourly/daily rate in AUD if mentioned),
   140→  "rateMax": number or null (maximum hourly/daily rate in AUD if mentioned),
   141→  "rateType": "hourly" or "daily" (default "hourly"),
   142→  "suburb": "suburb or area name if mentioned, or null",
   143→  "siteAddress": "full site address if mentioned, or null",
   144→  "startDate": "ISO date string (YYYY-MM-DD) if a start date is mentioned, or null. Convert relative dates like 'next Monday' based on today being ${new Date().toISOString().split("T")[0]}.",
   145→  "duration": "duration string if mentioned (e.g. '2 weeks', 'ongoing'), or null",
   146→  "workersNeeded": number (default 1, extract from voice if mentioned)
   147→}
   148→
   149→Important:
   150→- If a rate like "45 bucks an hour" is mentioned, set rateMin to that value and rateMax to rateMin + 10.
   151→- If a daily rate is mentioned (e.g. "$400 a day"), set rateType to "daily".
   152→- Always generate a professional title and description even if the voice input is casual.
   153→- Capture ALL details mentioned — location, timing, number of workers, rates, specific requirements.`,
   154→          },
   155→          {
   156→            role: "user",
   157→            content: sanitizedTranscript,
   158→          },
   159→        ],
   160→        temperature: 0.3,
   161→        response_format: { type: "json_object" },
   162→      });
   163→
   164→      const content = completion.choices[0]?.message?.content;
   165→      if (!content) {
   166→        throw new Error("No response from AI");
   167→      }
   168→
   169→      const generated = JSON.parse(content);
   170→
   171→      // Validate trade
   172→      const trade = VALID_TRADES.includes(generated.trade) ? generated.trade : "other";
   173→
   174→      // Validate and clamp rates
   175→      const MIN_RATE = 25;
   176→      const MAX_RATE = 500;
   177→      let rateMin = typeof generated.rateMin === "number" ? generated.rateMin : null;
   178→      let rateMax = typeof generated.rateMax === "number" ? generated.rateMax : null;
   179→
   180→      if (rateMin !== null) {
   181→        rateMin = Math.max(MIN_RATE, Math.min(rateMin, MAX_RATE));
   182→      }
   183→      if (rateMax !== null) {
   184→        rateMax = Math.max(MIN_RATE, Math.min(rateMax, MAX_RATE));
   185→      }
   186→      if (rateMin !== null && rateMax !== null && rateMax < rateMin) {
   187→        [rateMin, rateMax] = [rateMax, rateMin];
   188→      }
   189→
   190→      // Validate workersNeeded
   191→      const workersNeeded = typeof generated.workersNeeded === "number" && generated.workersNeeded >= 1
   192→        ? Math.min(Math.round(generated.workersNeeded), 50)
   193→        : 1;
   194→
   195→      return NextResponse.json({
   196→        transcript: sanitizedTranscript,
   197→        trade,
   198→        title: stripContactInfo(generated.title || ""),
   199→        description: stripContactInfo(generated.description || ""),
   200→        certifications: Array.isArray(generated.certifications) ? generated.certifications : ["White Card"],
   201→        rateMin,
   202→        rateMax,
   203→        rateType: generated.rateType === "daily" ? "daily" : "hourly",
   204→        suburb: generated.suburb || null,
   205→        siteAddress: generated.siteAddress || null,
   206→        startDate: generated.startDate || null,
   207→        duration: generated.duration || null,
   208→        workersNeeded,
   209→      });
   210→    } catch (error) {
   211→      logger.error("Voice-to-job AI processing error:", error);
   212→
   213→      // Return transcript so user can still use manual entry
   214→      return NextResponse.json({
   215→        transcript: sanitizedTranscript,
   216→        error: "AI processing failed. Please fill in the details manually.",
   217→        fallback: true,
   218→      });
   219→    }
   220→  } catch (error) {
   221→    logger.error("Voice-to-job error:", error);
   222→
   223→    if (error instanceof Error) {
   224→      if (error.message.includes("API key")) {
   225→        return NextResponse.json({ error: "AI service configuration error" }, { status: 503 });
   226→      }
   227→      if (error.message.includes("rate limit") || error.message.includes("429")) {
   228→        return NextResponse.json({ error: "AI service is busy. Please try again." }, { status: 429 });
   229→      }
   230→    }
   231→
   232→    return NextResponse.json(
   233→      { error: "Failed to process voice input. Please try again." },
   234→      { status: 500 }
   235→    );
   236→  }
   237→};
   238→
   239→export const POST = withCSRFProtection(handler);
   240→

<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>


Perfect! Let me now check the analysis files that were mentioned in the glob results:


     1→# Voice Processing Flow Analysis - RateRight App
     2→
     3→## Current Flow Analysis
     4→
     5→### Sequential API Call Chain
     6→The voice processing flow currently makes **3 sequential API calls**:
     7→
     8→1. **Transcription** (`/api/ai/transcribe-voice`)
     9→   - Uses OpenAI Whisper API
    10→   - Takes audio blob → returns text transcript
    11→   - Network + Whisper processing time: ~2-5 seconds
    12→
    13→2. **Profile Parsing** (`/api/ai/parse-profile-voice`)
    14→   - Uses OpenAI GPT-4o-mini
    15→   - Takes transcript → returns structured profile data
    16→   - Network + GPT processing time: ~1-3 seconds
    17→
    18→3. **Profile Generation** (`/api/ai/generate-profile`)
    19→   - Uses OpenAI GPT-4o-mini
    20→   - Takes structured data + transcript → returns bio/skills/certs
    21→   - Network + GPT processing time: ~2-4 seconds
    22→
    23→**Total sequential time: ~5-12 seconds** (plus network overhead)
    24→
    25→### Code Flow Location
    26→The sequential calls happen in:
    27→- File: `/src/app/(authenticated)/worker/profile/build/page.tsx`
    28→- Function: `handleVoiceComplete()` (lines 158-250)
    29→- Current implementation: Each call waits for the previous to complete
    30→
    31→## Identified Issues
    32→
    33→### 1. Sequential Dependencies
    34→- Profile parsing must wait for transcription
    35→- Profile generation must wait for parsing
    36→- No parallelization possible due to data dependencies
    37→
    38→### 2. Redundant Processing
    39→- The transcript is sent to both parsing AND generation endpoints
    40→- Generation endpoint re-processes the same transcript data
    41→
    42→### 3. Network Overhead
    43→- 3 separate HTTP requests with associated latency
    44→- JSON serialization/deserialization overhead
    45→
    46→### 4. Error Handling Gaps
    47→- If parsing fails, user loses all voice data
    48→- No retry mechanism for failed calls
    49→- Transcript is lost if subsequent calls fail
    50→
    51→## Optimization Opportunities
    52→
    53→### 1. API Consolidation (High Impact)
    54→**Combine parsing + generation into single call**
    55→- Merge `/api/ai/parse-profile-voice` + `/api/ai/generate-profile`
    56→- Single GPT call can handle both parsing and generation
    57→- **Estimated improvement: ~30-50% faster**
    58→
    59→### 2. Streaming Response (Medium Impact)
    60→**Implement streaming for real-time feedback**
    61→- Stream transcription progress
    62→- Stream generation progress
    63→- Better UX with perceived performance
    64→
    65→### 3. Intelligent Caching (Medium Impact)
    66→**Cache successful transcription results**
    67→- Store transcript hash → parsed data mapping
    68→- Avoid re-parsing similar voice inputs
    69→- Redis/memory cache for 24 hours
    70→
    71→### 4. Async Queue Processing (Low-Medium Impact)
    72→**Queue expensive generation operations**
    73→- Return immediately with "processing" status
    74→- WebSocket/polling for completion
    75→- Better for poor network conditions
    76→
    77→### 5. Fallback Strategy (High Impact)
    78→**Graceful degradation on failures**
    79→- If generation fails, allow manual form completion with transcript
    80→- If parsing fails, show transcript for manual entry
    81→- Preserve user data across failures
    82→
    83→## Recommended Implementation Plan
    84→
    85→### Phase 1: API Consolidation (Immediate)
    86→1. Create new endpoint: `/api/ai/voice-complete`
    87→2. Merge parsing + generation logic
    88→3. Update frontend to use new endpoint
    89→4. Maintain backward compatibility
    90→
    91→### Phase 2: Enhanced Error Handling (Immediate)
    92→1. Add transcript preservation on failures
    93→2. Implement fallback to manual entry
    94→3. Add retry mechanisms with exponential backoff
    95→
    96→### Phase 3: Performance Monitoring (Short-term)
    97→1. Add timing metrics to each step
    98→2. Implement performance logging
    99→3. Set up alerts for slow operations
   100→
   101→### Phase 4: Streaming Implementation (Medium-term)
   102→1. Implement SSE (Server-Sent Events) for progress updates
   103→2. Add streaming support to OpenAI calls
   104→3. Update UI for real-time feedback
   105→
   106→## Expected Performance Improvements
   107→
   108→- **Phase 1**: 30-50% reduction in total time (~3.5-7 seconds)
   109→- **Phase 2**: Better user experience, reduced abandonment
   110→- **Phase 3**: Data-driven optimization opportunities
   111→- **Phase 4**: Perceived performance improvement, better UX
   112→
   113→## Risk Assessment
   114→
   115→### Low Risk
   116→- API consolidation (maintains same logic)
   117→- Error handling improvements
   118→- Performance monitoring
   119→
   120→### Medium Risk
   121→- Streaming implementation (requires UI changes)
   122→- Caching layer (requires infrastructure)
   123→
   124→### High Risk
   125→- Major architectural changes (async queues)
   126→
   127→## Next Steps
   128→
   129→1. Implement Phase 1 (API consolidation) - can be done immediately
   130→2. Add comprehensive error handling
   131→3. Deploy and measure performance improvements
   132→4. Plan Phase 2+ based on results and user feedback

<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→# Voice Processing Optimization - Implementation Summary
     2→
     3→## Changes Made
     4→
     5→### 1. New Consolidated API Endpoint
     6→**File:** `/src/app/api/ai/voice-complete/route.ts`
     7→
     8→**Features:**
     9→- Single endpoint that handles transcription + parsing + profile generation
    10→- Supports both audio file upload and existing transcript input
    11→- Comprehensive error handling with fallback to manual completion
    12→- Performance monitoring with timing data
    13→- Maintains CSRF protection and rate limiting
    14→
    15→**Key Optimizations:**
    16→- Merged 2 GPT-4o-mini calls into 1 (parsing + generation)
    17→- Reduced network overhead from 3 HTTP requests to 1
    18→- Parallel processing where possible (transcription + AI processing)
    19→
    20→### 2. Frontend Updates
    21→**File:** `/src/app/(authenticated)/worker/profile/build/page.tsx`
    22→
    23→**Changes:**
    24→- Updated `handleVoiceComplete()` to use new consolidated endpoint
    25→- Added support for receiving generated profile data directly
    26→- Enhanced error handling with fallback to manual entry
    27→- Added progress indicators and user feedback
    28→- Preserves transcript data across failures
    29→
    30→### 3. Performance Improvements
    31→
    32→**Before Optimization:**
    33→```
    34→Sequential Flow: 5-12 seconds total
    35→├── Transcription (Whisper): 2-5 seconds
    36→├── Parsing (GPT-4o-mini): 1-3 seconds
    37→└── Generation (GPT-4o-mini): 2-4 seconds
    38→```
    39→
    40→**After Optimization:**
    41→```
    42→Consolidated Flow: 3-7 seconds total
    43→├── [Transcription (Whisper)]: 2-5 seconds (if audio provided)
    44→└── Parse+Generate (GPT-4o-mini): 1-2 seconds
    45→```
    46→
    47→**Expected Improvement: 30-50% faster processing**
    48→
    49→## Enhanced Error Handling
    50→
    51→### Fallback Mechanisms
    52→1. **AI Processing Failure**: Returns transcript for manual completion
    53→2. **Network Issues**: Preserves user data for retry
    54→3. **Rate Limiting**: Clear error messages with retry guidance
    55→4. **Audio Issues**: Specific error messages for different failure modes
    56→
    57→### User Experience Improvements
    58→- Better progress indicators during processing
    59→- Clear error messages with actionable next steps
    60→- Option to complete profile manually if AI fails
    61→- Preserved voice data across failures
    62→
    63→## API Changes
    64→
    65→### New Endpoint
    66→```
    67→POST /api/ai/voice-complete
    68→Content-Type: multipart/form-data or application/json
    69→
    70→Request (FormData):
    71→- audio: File (optional if transcript provided)
    72→- transcript: string (optional if audio provided)
    73→
    74→Request (JSON):
    75→{
    76→  "transcript": "I'm a chippie with 8 years experience..."
    77→}
    78→
    79→Response:
    80→{
    81→  "transcript": "processed text",
    82→  "parsed_data": { /* structured profile data */ },
    83→  "generated_profile": { /* bio, skills, certifications */ },
    84→  "error?": "error message if failed",
    85→  "fallback?": true if AI processing failed,
    86→  "timing": { "had_audio": boolean, "processing_complete": boolean }
    87→}
    88→```
    89→
    90→### Backward Compatibility
    91→- Existing endpoints remain functional
    92→- Gradual migration path for other parts of the app
    93→- No breaking changes to existing APIs
    94→
    95→## Testing Strategy
    96→
    97→### Manual Testing Checklist
    98→- [ ] Audio file upload with complete profile data
    99→- [ ] Transcript-only processing
   100→- [ ] Partial data extraction (missing fields)
   101→- [ ] AI processing failure fallback
   102→- [ ] Rate limiting behavior
   103→- [ ] Network error handling
   104→- [ ] Large audio file handling
   105→
   106→### Performance Monitoring
   107→- Added timing data to response for performance tracking
   108→- Error logging for failure analysis
   109→- Rate limit tracking per endpoint
   110→
   111→## Next Steps
   112→
   113→### Immediate
   114→1. Deploy the new endpoint
   115→2. Monitor performance metrics
   116→3. Collect user feedback on speed improvements
   117→
   118→### Future Enhancements
   119→1. **Streaming Response**: Implement SSE for real-time progress updates
   120→2. **Caching Layer**: Cache successful parsing results for similar transcripts
   121→3. **Audio Optimization**: Compress audio before upload to reduce upload time
   122→4. **Smart Retry**: Implement exponential backoff for failed requests
   123→
   124→### Monitoring
   125→- Track average processing times
   126→- Monitor error rates and types
   127→- Measure user completion rates
   128→- Analyze fallback usage
   129→
   130→## Risk Mitigation
   131→
   132→### Low Risk Changes
   133→- ✅ New endpoint doesn't affect existing functionality
   134→- ✅ Comprehensive error handling with fallbacks
   135→- ✅ Maintains all security measures (CSRF, rate limiting)
   136→
   137→### Monitoring Points
   138→- OpenAI API usage and costs
   139→- Error rates on new endpoint
   140→- User completion rates vs. old flow
   141→- Performance metrics comparison
   142→
   143→## Conclusion
   144→
   145→This optimization successfully addresses the sequential API call bottleneck by:
   146→1. Consolidating 3 API calls into 1-2 calls
   147→2. Reducing total processing time by 30-50%
   148→3. Improving error handling and user experience
   149→4. Maintaining backward compatibility
   150→
   151→The implementation provides a solid foundation for future voice processing enhancements while delivering immediate performance improvements to users.

<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>


Perfect! Now let me verify the error handling for the "Voice processing failed" toast by checking the exact message used:


src/app/(authenticated)/contractor/post-job/page.tsx:252:      toast.error("Voice processing failed. Try again or type your job details.");


Excellent! Now let me create a comprehensive summary. Let me also check the service worker for any voice-related code:


find /home/ccuser/the-50-dollar-app/public -name "*sw*" -o -name "*worker*" -o -name "*service*" 2>/dev/null | head -20


total 1420
drwxrwxr-x  4 ccuser ccuser   4096 Feb 26 05:35 .
drwxrwxr-x 15 ccuser ccuser   4096 Mar 12 08:16 ..
-rw-r--r--  1 ccuser ccuser  10984 Feb 16 08:14 apple-touch-icon.png
-rw-rw-r--  1 ccuser ccuser    297 Feb  8 11:18 apple-touch-icon.svg
-rw-rw-r--  1 ccuser ccuser    714 Feb 16 08:14 favicon-16x16.png
-rw-rw-r--  1 ccuser ccuser   1823 Feb 16 08:14 favicon-32x32.png
-rw-rw-r--  1 ccuser ccuser   1823 Feb 16 08:14 favicon.ico
-rw-rw-r--  1 ccuser ccuser    391 Feb  8 11:18 file.svg
-rw-rw-r--  1 ccuser ccuser   1035 Feb  8 11:18 globe.svg
-rw-rw-r--  1 ccuser ccuser 888627 Feb  8 11:18 hero-image.png
-rw-rw-r--  1 ccuser ccuser   8777 Feb 16 08:14 icon-144x144.png
-rw-r--r--  1 root   root      714 Feb 16 08:14 icon-16x16.png
-rw-r--r--  1 ccuser ccuser  42069 Feb  9 03:29 icon-192.png
-rw-r--r--  1 root   root    11957 Feb 16 08:14 icon-192x192.png
-rw-rw-r--  1 ccuser ccuser    297 Feb  8 11:18 icon-192x192.svg
-rw-r--r--  1 root   root     1823 Feb 16 08:14 icon-32x32.png
-rw-r--r--  1 ccuser ccuser   2837 Feb 16 08:14 icon-48x48.png
-rw-r--r--  1 ccuser ccuser 289643 Feb  9 03:29 icon-512.png
-rw-r--r--  1 root   root    34706 Feb 16 08:14 icon-512x512.png
-rw-rw-r--  1 ccuser ccuser    299 Feb  8 11:18 icon-512x512.svg
-rw-r--r--  1 ccuser ccuser   4249 Feb 16 08:14 icon-72x72.png
-rw-r--r--  1 ccuser ccuser   5730 Feb 16 08:14 icon-96x96.png
-rw-r--r--  1 ccuser ccuser  32374 Feb 16 08:14 icon-maskable.png
-rw-rw-r--  1 ccuser ccuser   6195 Feb  8 11:18 logo_black.svg
-rw-rw-r--  1 ccuser ccuser   6351 Feb  8 11:18 logo_white.svg
drwxr-xr-x  2 root   root     4096 Feb 15 20:35 logos
-rw-rw-r--  1 ccuser ccuser   1045 Feb 16 08:14 manifest.json
-rw-rw-r--  1 ccuser ccuser   1375 Feb  8 11:18 next.svg
-rw-rw-r--  1 ccuser ccuser   3637 Feb  8 11:18 pwa-test.html
drwxr-xr-x  2 root   root     4096 Feb 26 05:35 qr
-rw-rw-r--  1 ccuser ccuser    187 Feb  9 04:01 register-sw.js
-rw-rw-r--  1 ccuser ccuser   2629 Feb 14 14:38 sw.js
-rw-rw-r--  1 ccuser ccuser    128 Feb  8 11:18 vercel.svg
-rw-rw-r--  1 ccuser ccuser    385 Feb  8 11:18 window.svg


/home/ccuser/the-50-dollar-app/public/sw.js
/home/ccuser/the-50-dollar-app/public/qr/hostel-jolly-swagman-backpackers-sydney.png
/home/ccuser/the-50-dollar-app/public/register-sw.js


Let me quickly check the service workers: