Thoroughly explore the RateRight codebase to identify ALL potential leakage points where users could bypass the $50 platform fee. I need to find every place where contact information might be exposed before payment.

Specifically search for:

1. **Worker profile data exposed before payment** - Check what data is shown on match cards in /contractor/matches. Are phone numbers, emails, full names, or social media visible before hiring?

2. **Job posting data that leaks contractor info** - Check what data workers see in /worker/jobs. Are company names, ABNs, phone numbers, addresses, or contact details visible?

3. **Messaging access controls** - Can users message each other before a hire? What guards exist on /api/messages/start and the messages UI?

4. **Profile viewing** - Can a contractor view a worker's full profile before payment? Can a worker view a contractor's full profile?

5. **URL patterns** - Are there public profile routes like /worker/[id] or /contractor/[id] that could expose info?

6. **Database RLS policies** - What row-level security exists on profiles, worker_profiles, companies tables? Can authenticated users query other users' contact info directly?

7. **API endpoint data exposure** - Do any API responses include phone, email, or other contact info for non-hired matches?

8. **Match data** - What worker data is included in the match response? Does match_score or ai_summary contain contact info?

9. **Notification content** - Do notifications reveal contact info?

10. **Rating system** - Can users see who rated them (full profile with contact)?

For each finding, note:
- The exact file path and line numbers
- What data is exposed
- Whether it's a real bypass risk or properly guarded
- Severity (critical/high/medium/low)

Be very thorough - this is a security/revenue audit.


I'll conduct a comprehensive security audit of the RateRight codebase, searching for all potential fee bypass vulnerabilities. Let me start by exploring the structure and then systematically checking each area you mentioned.


Bash

find /home/ccuser/the-50-dollar-app -type f -name "*.ts" -o -name "*.tsx" -o -name "*.sql" | head -30


Let me explore the app structure more systematically:


ls -la /home/ccuser/the-50-dollar-app/app/ | head -30


ls: cannot access '/home/ccuser/the-50-dollar-app/app/': No such file or directory


find /home/ccuser/the-50-dollar-app -type d -name "app" -o -type d -name "src"


Good! Now let me explore the source structure:


find /home/ccuser/the-50-dollar-app/src/app -type f -name "*.ts" -o -name "*.tsx" | grep -E "(contractor|worker)" | head -20


find /home/ccuser/the-50-dollar-app/src -type d | grep -E "(contractor|worker|api|routes)" | head -20


/home/ccuser/the-50-dollar-app/src/app/api
/home/ccuser/the-50-dollar-app/src/app/api/auth
/home/ccuser/the-50-dollar-app/src/app/api/auth/csrf
/home/ccuser/the-50-dollar-app/src/app/api/auth/verify-email
/home/ccuser/the-50-dollar-app/src/app/api/payments
/home/ccuser/the-50-dollar-app/src/app/api/payments/verify
/home/ccuser/the-50-dollar-app/src/app/api/payments/create
/home/ccuser/the-50-dollar-app/src/app/api/ai
/home/ccuser/the-50-dollar-app/src/app/api/ai/transcribe-voice
/home/ccuser/the-50-dollar-app/src/app/api/ai/parse-profile-voice
/home/ccuser/the-50-dollar-app/src/app/api/ai/generate-profile
/home/ccuser/the-50-dollar-app/src/app/api/ai/voice-complete-rootfix
/home/ccuser/the-50-dollar-app/src/app/api/ai/voice-complete
/home/ccuser/the-50-dollar-app/src/app/api/ai/generate-job
/home/ccuser/the-50-dollar-app/src/app/api/ratings-rootfix
/home/ccuser/the-50-dollar-app/src/app/api/webhooks
/home/ccuser/the-50-dollar-app/src/app/api/webhooks/stripe
/home/ccuser/the-50-dollar-app/src/app/api/company-logo
/home/ccuser/the-50-dollar-app/src/app/api/match
/home/ccuser/the-50-dollar-app/src/app/api/match/find-matches


find /home/ccuser/the-50-dollar-app/src/app -type d -maxdepth 3 | grep -E "(contractor|worker)" | head -30


Perfect! Now let me systematically audit each critical area. Let me start with the core files:


/home/ccuser/the-50-dollar-app/src/app/(authenticated)/contractor/matches/loading.tsx
/home/ccuser/the-50-dollar-app/src/app/(authenticated)/contractor/matches/page.tsx


/home/ccuser/the-50-dollar-app/src/app/(authenticated)/worker/jobs/loading.tsx
/home/ccuser/the-50-dollar-app/src/app/(authenticated)/worker/jobs/page.tsx


Excellent. Now let me read the critical files. Let me start with the contractor matches page:


1→"use client";
     2→
     3→import { useEffect, useState, useRef } from "react";
     4→import Link from "next/link";
     5→import toast from "react-hot-toast";
     6→import { logger } from "@/lib/logger";
     7→import { createClient } from "@/lib/supabase/client";
     8→import { Card, CardContent } from "@/components/ui/card";
     9→import { Badge } from "@/components/ui/badge";
    10→import { Button } from "@/components/ui/button";
    11→import { BottomNav } from "@/components/bottom-nav";
    12→import { HirePaymentModal } from "@/components/payment/HirePaymentModal";
    13→import {
    14→  ArrowLeft,
    15→  Loader2,
    16→  Star,
    17→  MapPin,
    18→  Clock,
    19→  CheckCircle2,
    20→  UserCheck,
    21→  HardHat,
    22→  AlertCircle,
    23→} from "lucide-react";
    24→import type { Job } from "@/lib/types";
    25→import { postgresPointToJson } from "@/lib/location-utils";
    26→import { TRADE_CATEGORIES } from "@/lib/constants";
    27→import { PAYMENT_AMOUNT } from "@/lib/constants";
    28→
    29→interface MatchWithWorker {
    30→  id: string;
    31→  job_id: string;
    32→  worker_id: string;
    33→  status: string;
    34→  match_score: number | null;
    35→  ai_summary: string | null;
    36→  contractor_action: string;
    37→  worker_action: string;
    38→  hired_at: string | null;
    39→  fee_charged: boolean;
    40→  created_at: string;
    41→  worker: {
    42→    id: string;
    43→    name: string | null;
    44→    suburb: string | null;
    45→    avatar_url: string | null;
    46→  } | null;
    47→  worker_profile: {
    48→    trades: string[];
    49→    experience_years: number | null;
    50→    certifications: string[];
    51→    availability: string | null;
    52→  } | null;
    53→}
    54→
    55→interface SwipeableMatchCardProps {
    56→  match: MatchWithWorker;
    57→  isHired: boolean;
    58→  justHired: boolean;
    59→  hiring: boolean;
    60→  onHire: (match: MatchWithWorker) => void;
    61→  onReject: (match: MatchWithWorker) => void;
    62→}
    63→
    64→function SwipeableMatchCard({ match, isHired, justHired, hiring, onHire, onReject }: SwipeableMatchCardProps) {
    65→  const [swipeX, setSwipeX] = useState(0);
    66→  const [swiping, setSwiping] = useState(false);
    67→  const startX = useRef(0);
    68→  const currentX = useRef(0);
    69→
    70→  const summary = match.ai_summary || generateAiSummary(match.worker, match.worker_profile);
    71→
    72→  const handleTouchStart = (e: React.TouchEvent) => {
    73→    if (isHired) return;
    74→    startX.current = e.touches[0].clientX;
    75→    setSwiping(true);
    76→  };
    77→
    78→  const handleTouchMove = (e: React.TouchEvent) => {
    79→    if (!swiping || isHired) return;
    80→    currentX.current = e.touches[0].clientX;
    81→    const diff = currentX.current - startX.current;
    82→    // Allow both directions
    83→    setSwipeX(Math.max(-150, Math.min(diff, 150)));
    84→  };
    85→
    86→  const handleTouchEnd = () => {
    87→    if (!swiping || isHired) return;
    88→    setSwiping(false);
    89→
    90→    // If swiped right > 100px, hire
    91→    if (swipeX > 100) {
    92→      onHire(match);
    93→    }
    94→    // If swiped left > 100px, reject
    95→    else if (swipeX < -100) {
    96→      onReject(match);
    97→    }
    98→
    99→    // Reset
   100→    setSwipeX(0);
   101→  };
   102→
   103→  const swipeProgress = Math.abs(swipeX) / 100;
   104→  const swipeDirection = swipeX > 0 ? "right" : "left";
   105→
   106→  return (
   107→

   108→      {/* Swipe indicator backgrounds */}
   109→      {!isHired && swipeX !== 0 && (
   110→        <>
   111→          {/* Right swipe (hire) */}
   112→          {swipeX > 0 && (
   113→

   120→              <UserCheck className="h-8 w-8 text-green-600" />
   121→
   122→                {swipeProgress >= 1 ? "Release to hire" : "Swipe to hire"}
   123→
   124→

   125→          )}
   126→          {/* Left swipe (reject) */}
   127→          {swipeX < 0 && (
   128→

   135→
   136→                {swipeProgress >= 1 ? "Release to pass" : "Swipe to pass"}
   137→
   138→              <AlertCircle className="h-8 w-8 text-red-600" />
   139→

   140→          )}
   141→        </>
   142→      )}
   143→
   144→      <Card
   145→        className={`transition-all touch-pan-y ${justHired ? "ring-2 ring-green-500" : ""}`}
   146→        style={{
   147→          transform: `translateX(${swipeX}px)`,
   148→          transition: swiping ? 'none' : 'transform 0.2s ease-out',
   149→        }}
   150→        onTouchStart={handleTouchStart}
   151→        onTouchMove={handleTouchMove}
   152→        onTouchEnd={handleTouchEnd}
   153→      >
   154→        <CardContent className="p-5">
   155→

   156→            {/* Avatar */}
   157→

   158→              {match.worker?.name?.[0]?.toUpperCase() || "?"}
   159→

   160→
   161→

   162→

   163→

   164→                  {match.worker?.name || "Worker"}
   165→

   166→                {isHired && (
   167→                  <Badge className="bg-green-600 text-xs shrink-0">Hired</Badge>
   168→                )}
   169→

   170→
   171→              {/* AI Summary */}
   172→

   173→                {summary}
   174→

   175→
   176→              {/* Details */}
   177→

   178→                {match.worker_profile?.experience_years && (
   179→
   180→                    <Clock className="h-4 w-4" />
   181→                    {match.worker_profile.experience_years}yr exp
   182→
   183→                )}
   184→                {match.worker?.suburb && (
   185→
   186→                    <MapPin className="h-4 w-4" />
   187→                    {match.worker.suburb}
   188→
   189→                )}
   190→                {match.match_score && (
   191→
   192→                    <Star className="h-4 w-4 fill-amber-400 text-amber-400" />
   193→                    {Math.round(match.match_score)}% match
   194→
   195→                )}
   196→

   197→
   198→              {/* Certs */}
   199→              {match.worker_profile?.certifications && match.worker_profile.certifications.length > 0 && (
   200→

   201→                  {match.worker_profile.certifications.slice(0, 3).map((cert) => (
   202→                    <Badge key={cert} variant="outline" className="text-xs px-2 py-1">
   203→                      {cert}
   204→                    </Badge>
   205→                  ))}
   206→

   207→              )}
   208→

   209→

   210→
   211→          {/* Action */}
   212→          {!isHired && (
   213→            <Button
   214→              onClick={() => onHire(match)}
   215→              className="mt-4 w-full h-12 text-base font-semibold"
   216→              disabled={hiring}
   217→            >
   218→              {hiring ? (
   219→                <>
   220→                  <Loader2 className="mr-2 h-5 w-5 animate-spin" />
   221→                  Processing...
   222→                </>
   223→              ) : (
   224→                <>
   225→                  <UserCheck className="mr-2 h-5 w-5" />
   226→                  Hire — $50 Fee
   227→                </>
   228→              )}
   229→            </Button>
   230→          )}
   231→
   232→          {justHired && (
   233→

   234→              <CheckCircle2 className="h-5 w-5" />
   235→              Hired! Worker has been notified.
   236→

   237→          )}
   238→
   239→          {isHired && !justHired && (
   240→

   241→              <CheckCircle2 className="h-5 w-5 text-green-600" />
   242→              Hired on{" "}
   243→              {match.hired_at
   244→                ? new Date(match.hired_at).toLocaleDateString("en-AU", {
   245→                    day: "numeric",
   246→                    month: "short",
   247→                  })
   248→                : "recently"}
   249→

   250→          )}
   251→        </CardContent>
   252→      </Card>
   253→

   254→  );
   255→}
   256→
   257→const TRADE_LABEL: Record<string, string> = TRADE_CATEGORIES.reduce((acc, cat) => {
   258→  acc[cat.id] = cat.label;
   259→  return acc;
   260→}, {} as Record<string, string>);
   261→
   262→function generateAiSummary(
   263→  worker: MatchWithWorker["worker"],
   264→  profile: MatchWithWorker["worker_profile"]
   265→): string {
   266→  const parts: string[] = [];
   267→  if (profile?.trades?.length) {
   268→    parts.push(profile.trades.map((t) => TRADE_LABEL[t] || t).join(", "));
   269→  }
   270→  if (profile?.experience_years) {
   271→    parts.push(`${profile.experience_years} years exp`);
   272→  }
   273→  if (worker?.suburb) {
   274→    parts.push(worker.suburb);
   275→  }
   276→  if (profile?.availability) {
   277→    parts.push(profile.availability);
   278→  }
   279→  if (profile?.certifications?.length) {
   280→    parts.push(profile.certifications.slice(0, 2).join(", "));
   281→  }
   282→  return parts.join(" \u00B7 ") || "Worker profile";
   283→}
   284→
   285→export default function ContractorMatchesPage() {
   286→  const supabase = createClient();
   287→  const [jobs, setJobs] = useState<Job[]>([]);
   288→  const [selectedJobId, setSelectedJobId] = useState<string | null>(null);
   289→  const [matches, setMatches] = useState<MatchWithWorker[]>([]);
   290→  const [loading, setLoading] = useState(true);
   291→  const [loadingMatches, setLoadingMatches] = useState(false);
   292→  const [hiring, setHiring] = useState<string | null>(null);
   293→  const [hireSuccess, setHireSuccess] = useState<string | null>(null);
   294→  const [currentPage, setCurrentPage] = useState(1);
   295→  const [totalPages, setTotalPages] = useState(1);
   296→  const [totalCount, setTotalCount] = useState(0);
   297→  const ITEMS_PER_PAGE = 10;
   298→
   299→  // Payment modal state
   300→  const [companyId, setCompanyId] = useState<string | null>(null);
   301→  const [selectedMatch, setSelectedMatch] = useState<MatchWithWorker | null>(null);
   302→  const [isPaymentModalOpen, setIsPaymentModalOpen] = useState(false);
   303→
   304→  // Load contractor's jobs
   305→  useEffect(() => {
   306→    async function load() {
   307→      const { data: { user } } = await supabase.auth.getUser();
   308→      if (!user) return;
   309→
   310→      const { data: company } = await supabase
   311→        .from("companies")
   312→        .select("id")
   313→        .eq("profile_id", user.id)
   314→        .single();
   315→
   316→      if (!company) {
   317→        setLoading(false);
   318→        return;
   319→      }
   320→
   321→      setCompanyId(company.id);
   322→
   323→      const { data: jobsData } = await supabase
   324→        .from("jobs")
   325→        .select("id, company_id, status, created_at")
   326→        .eq("company_id", company.id)
   327→        .in("status", ["active", "filled"])
   328→        .order("created_at", { ascending: false });
   329→
   330→      if (jobsData && jobsData.length > 0) {
   331→        // Convert PostgreSQL point to JSON format
   332→        const convertedJobs = jobsData.map((job: any) => ({
   333→          ...job,
   334→          location: postgresPointToJson(job.location)
   335→        }));
   336→        setJobs(convertedJobs);
   337→        setSelectedJobId(jobsData[0].id);
   338→      }
   339→      setLoading(false);
   340→    }
   341→    load();
   342→  }, [supabase]);
   343→
   344→  // Load matches for selected job
   345→  useEffect(() => {
   346→    if (!selectedJobId) return;
   347→    async function loadMatches() {
   348→      setLoadingMatches(true);
   349→
   350→      // Get total count first
   351→      const { count } = await supabase
   352→        .from("matches")
   353→        .select("*", { count: "exact", head: true })
   354→        .eq("job_id", selectedJobId);
   355→
   356→      if (count) {
   357→        setTotalCount(count);
   358→        setTotalPages(Math.ceil(count / ITEMS_PER_PAGE));
   359→      }
   360→
   361→      // Calculate pagination range
   362→      const from = (currentPage - 1) * ITEMS_PER_PAGE;
   363→      const to = from + ITEMS_PER_PAGE - 1;
   364→
   365→      // Get matches for this job with pagination
   366→      const { data: matchData } = await supabase
   367→        .from("matches")
   368→        .select("id, job_id, worker_id, status, match_score, ai_summary, contractor_action, worker_action, hired_at, fee_charged, created_at")
   369→        .eq("job_id", selectedJobId)
   370→        .order("match_score", { ascending: false })
   371→        .range(from, to);
   372→
   373→      if (!matchData || matchData.length === 0) {
   374→        setMatches([]);
   375→        setLoadingMatches(false);
   376→        return;
   377→      }
   378→
   379→      // Fetch worker profiles and base profiles
   380→      const workerIds: string[] = [];
   381→      for (const m of matchData) {
   382→        if (m.worker_id) workerIds.push(m.worker_id);
   383→      }
   384→
   385→      if (workerIds.length === 0) {
   386→        setMatches([]);
   387→        setLoadingMatches(false);
   388→        return;
   389→      }
   390→
   391→      const [{ data: profiles }, { data: workerProfiles }] = await Promise.all([
   392→        supabase.from("profiles").select("id, name, suburb, avatar_url").in("id", workerIds),
   393→        supabase.from("worker_profiles").select("profile_id, trades, experience_years, certifications, availability").in("profile_id", workerIds),
   394→      ]);
   395→
   396→      type ProfileRow = { id: string; name: string | null; suburb: string | null; avatar_url: string | null };
   397→      type WPRow = { profile_id: string; trades: string[]; experience_years: number | null; certifications: string[]; availability: string | null };
   398→
   399→      const profileMap = new Map((profiles || []).map((p: ProfileRow) => [p.id, p]));
   400→      const wpMap = new Map((workerProfiles || []).map((w: WPRow) => [w.profile_id, w]));
   401→
   402→      const enriched: MatchWithWorker[] = matchData.map((m: Record<string, unknown>) => ({
   403→        id: m.id as string,
   404→        job_id: m.job_id as string,
   405→        worker_id: m.worker_id as string,
   406→        status: m.status as string,
   407→        match_score: m.match_score as number | null,
   408→        ai_summary: m.ai_summary as string | null,
   409→        contractor_action: m.contractor_action as string,
   410→        worker_action: m.worker_action as string,
   411→        hired_at: m.hired_at as string | null,
   412→        fee_charged: m.fee_charged as boolean,
   413→        created_at: m.created_at as string,
   414→        worker: profileMap.get(m.worker_id as string) || null,
   415→        worker_profile: wpMap.get(m.worker_id as string) || null,
   416→      }));
   417→
   418→      setMatches(enriched);
   419→      setLoadingMatches(false);
   420→    }
   421→    loadMatches();
   422→  }, [selectedJobId, supabase, currentPage]);
   423→
   424→  async function handleHire(match: MatchWithWorker) {
   425→    // Haptic feedback
   426→    if (navigator.vibrate) {
   427→      navigator.vibrate(50);
   428→    }
   429→
   430→    if (!companyId) {
   431→      toast.error("Company not found. Please complete your profile.");
   432→      return;
   433→    }
   434→
   435→    // Open payment modal
   436→    setSelectedMatch(match);
   437→    setIsPaymentModalOpen(true);
   438→  }
   439→
   440→  async function handlePaymentSuccess() {
   441→    if (!selectedMatch) return;
   442→
   443→    const supabase = createClient();
   444→    const hiredAt = new Date().toISOString();
   445→
   446→    // CRITICAL: Persist to database BEFORE updating local state
   447→    const { error } = await supabase
   448→      .from("matches")
   449→      .update({
   450→        status: "hired",
   451→        contractor_action: "accepted",
   452→        hired_at: hiredAt,
   453→        fee_charged: true,
   454→      })
   455→      .eq("id", selectedMatch.id);
   456→
   457→    if (error) {
   458→      logger.error("Failed to update match in database:", error);
   459→      alert("Failed to complete hire. Please try again.");
   460→      return;
   461→    }
   462→
   463→    // Update local state to show hired
   464→    setMatches((prev) =>
   465→      prev.map((m) =>
   466→        m.id === selectedMatch.id
   467→          ? { ...m, status: "hired", contractor_action: "accepted", hired_at: hiredAt, fee_charged: true }
   468→          : m
   469→      )
   470→    );
   471→
   472→    setHireSuccess(selectedMatch.id);
   473→    setIsPaymentModalOpen(false);
   474→    setSelectedMatch(null);
   475→    setTimeout(() => setHireSuccess(null), 3000);
   476→  }
   477→
   478→  async function handleReject(match: MatchWithWorker) {
   479→    // Haptic feedback
   480→    if (navigator.vibrate) {
   481→      navigator.vibrate([30, 20, 30]);
   482→    }
   483→
   484→    // Remove from list (soft delete - just update status)
   485→    await supabase
   486→      .from("matches")
   487→      .update({
   488→        contractor_action: "rejected",
   489→      })
   490→      .eq("id", match.id);
   491→
   492→    // Remove from UI
   493→    setMatches((prev) => prev.filter((m) => m.id !== match.id));
   494→  }
   495→
   496→  if (loading) {
   497→    return (
   498→

   499→        <Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
   500→

   501→    );
   502→  }
   503→
   504→  return (
   505→

   506→
   507→        {jobs.length === 0 ? (
   508→

   509→            <HardHat className="h-16 w-16 text-muted-foreground/50" />
   510→
No jobs posted yet

   511→

   512→              Post a job to start seeing matched workers.
   513→

   514→            <Button asChild className="mt-8 h-16 px-8 text-lg font-semibold">
   515→              <Link href="/contractor/post-job">Post a Job</Link>
   516→            </Button>
   517→

   518→        ) : (
   519→          <>
   520→            {/* Job selector — horizontal scroll */}
   521→            {jobs.length > 1 && (
   522→

   523→                {jobs.map((job) => (
   524→                  <button
   525→                    key={job.id}
   526→                    onClick={() => {
   527→                      setSelectedJobId(job.id);
   528→                      setCurrentPage(1); // Reset to first page when changing jobs
   529→                    }}
   530→                    className={`shrink-0 rounded-full px-5 py-3 text-base font-semibold transition-all active:scale-95 min-h-[48px] ${
   531→                      selectedJobId === job.id
   532→                        ? "bg-primary text-primary-foreground"
   533→                        : "bg-muted text-muted-foreground"
   534→                    }`}
   535→                  >
   536→                    {job.title}
   537→                  </button>
   538→                ))}
   539→

   540→            )}
   541→
   542→            {/* Selected job summary */}
   543→            {selectedJobId && (
   544→

   545→                {(() => {
   546→                  const job = jobs.find((j) => j.id === selectedJobId);
   547→                  if (!job) return null;
   548→                  return (
   549→

   550→                      <Badge variant="secondary">{TRADE_LABEL[job.trade] || job.trade}</Badge>
   551→                      {job.suburb && (
   552→
   553→                          <MapPin className="h-3.5 w-3.5" />
   554→                          {job.suburb}
   555→
   556→                      )}
   557→
   558→                        {matches.length} match{matches.length !== 1 ? "es" : ""}
   559→
   560→

   561→                  );
   562→                })()}
   563→

   564→            )}
   565→
   566→            {/* Matches */}
   567→            {loadingMatches ? (
   568→

   569→                <Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
   570→

   571→            ) : matches.length === 0 ? (
   572→

   573→                <UserCheck className="h-12 w-12 text-muted-foreground/50" />
   574→
No matches yet

   575→

   576→                  Workers will show up here as they apply. We'll notify you.
   577→

   578→

   579→            ) : (
   580→              <>
   581→

   582→                  {/* Swipe hint */}
   583→                  {matches.length > 0 && matches.some(m => m.status !== "hired") && (
   584→

   585→

   586→                        👉 Swipe right to hire • Swipe left to pass
   587→

   588→

   589→                  )}
   590→
   591→                  {matches.map((match) => (
   592→                    <SwipeableMatchCard
   593→                      key={match.id}
   594→                      match={match}
   595→                      isHired={match.status === "hired"}
   596→                      justHired={hireSuccess === match.id}
   597→                      hiring={hiring === match.id}
   598→                      onHire={handleHire}
   599→                      onReject={handleReject}
   600→                    />
   601→                  ))}
   602→

   603→
   604→                {/* Pagination Controls */}
   605→                {totalPages > 1 && (
   606→

   607→                    <Button
   608→                      variant="outline"
   609→                      size="sm"
   610→                      onClick={() => setCurrentPage(prev => Math.max(1, prev - 1))}
   611→                      disabled={currentPage === 1}
   612→                    >
   613→                      Previous
   614→                    </Button>
   615→
   616→

   617→                      {Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
   618→                        let pageNum;
   619→                        if (totalPages <= 5) {
   620→                          pageNum = i + 1;
   621→                        } else if (currentPage <= 3) {
   622→                          pageNum = i + 1;
   623→                        } else if (currentPage >= totalPages - 2) {
   624→                          pageNum = totalPages - 4 + i;
   625→                        } else {
   626→                          pageNum = currentPage - 2 + i;
   627→                        }
   628→
   629→                        return (
   630→                          <Button
   631→                            key={pageNum}
   632→                            variant={currentPage === pageNum ? "default" : "outline"}
   633→                            size="sm"
   634→                            onClick={() => setCurrentPage(pageNum)}
   635→                            className="min-w-[36px]"
   636→                          >
   637→                            {pageNum}
   638→                          </Button>
   639→                        );
   640→                      })}
   641→

   642→
   643→                    <Button
   644→                      variant="outline"
   645→                      size="sm"
   646→                      onClick={() => setCurrentPage(prev => Math.min(totalPages, prev + 1))}
   647→                      disabled={currentPage === totalPages}
   648→                    >
   649→                      Next
   650→                    </Button>
   651→

   652→                )}
   653→              </>
   654→            )}
   655→          </>
   656→        )}
   657→
   658→
   659→      <BottomNav userType="contractor" />
   660→
   661→      {/* Payment Modal */}
   662→      {selectedMatch && companyId && (
   663→        <HirePaymentModal
   664→          isOpen={isPaymentModalOpen}
   665→          onClose={() => {
   666→            setIsPaymentModalOpen(false);
   667→            setSelectedMatch(null);
   668→          }}
   669→          onSuccess={handlePaymentSuccess}
   670→          matchId={selectedMatch.id}
   671→          companyId={companyId}
   672→          amount={PAYMENT_AMOUNT}
   673→          workerName={selectedMatch.worker?.name || undefined}
   674→        />
   675→      )}
   676→

   677→  );
   678→}
   679→

<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 { useEffect, useState, useRef } from "react";
     4→import Link from "next/link";
     5→import { logger } from "@/lib/logger";
     6→import { createClient } from "@/lib/supabase/client";
     7→import { Card, CardContent } from "@/components/ui/card";
     8→import { Badge } from "@/components/ui/badge";
     9→import { Button } from "@/components/ui/button";
    10→import { BottomNav } from "@/components/bottom-nav";
    11→import {
    12→  MapPin,
    13→  Calendar,
    14→  DollarSign,
    15→  Loader2,
    16→  Search,
    17→  HardHat,
    18→  Check,
    19→  X,
    20→} from "lucide-react";
    21→import type { Job } from "@/lib/types";
    22→import { postgresPointToJson } from "@/lib/location-utils";
    23→import toast from "react-hot-toast";
    24→import { TRADE_CATEGORIES } from "@/lib/constants";
    25→
    26→const TRADE_FILTERS = [
    27→  "All",
    28→  ...TRADE_CATEGORIES.map(cat => cat.label),
    29→];
    30→
    31→const TRADE_ID_TO_LABEL: Record<string, string> = TRADE_CATEGORIES.reduce((acc, cat) => {
    32→  acc[cat.id] = cat.label;
    33→  return acc;
    34→}, {} as Record<string, string>);
    35→
    36→function tradeLabelFromId(id: string): string {
    37→  return TRADE_ID_TO_LABEL[id] || id;
    38→}
    39→
    40→interface SwipeableJobCardProps {
    41→  job: Job;
    42→  hasApplied: boolean;
    43→  onApply: (job: Job) => void;
    44→  onClick: (job: Job) => void;
    45→}
    46→
    47→function SwipeableJobCard({ job, hasApplied, onApply, onClick }: SwipeableJobCardProps) {
    48→  const [swipeX, setSwipeX] = useState(0);
    49→  const [swiping, setSwiping] = useState(false);
    50→  const startX = useRef(0);
    51→  const currentX = useRef(0);
    52→
    53→  const handleTouchStart = (e: React.TouchEvent) => {
    54→    if (hasApplied) return;
    55→    startX.current = e.touches[0].clientX;
    56→    setSwiping(true);
    57→  };
    58→
    59→  const handleTouchMove = (e: React.TouchEvent) => {
    60→    if (!swiping || hasApplied) return;
    61→    currentX.current = e.touches[0].clientX;
    62→    const diff = currentX.current - startX.current;
    63→    // Only allow right swipe (positive diff)
    64→    if (diff > 0) {
    65→      setSwipeX(Math.min(diff, 150));
    66→    }
    67→  };
    68→
    69→  const handleTouchEnd = () => {
    70→    if (!swiping || hasApplied) return;
    71→    setSwiping(false);
    72→
    73→    // If swiped more than 100px, apply
    74→    if (swipeX > 100) {
    75→      onApply(job);
    76→    }
    77→
    78→    // Reset
    79→    setSwipeX(0);
    80→  };
    81→
    82→  const swipeProgress = Math.min(swipeX / 100, 1);
    83→  const backgroundColor = hasApplied
    84→    ? "transparent"
    85→    : `rgba(34, 197, 94, ${swipeProgress * 0.1})`;
    86→
    87→  return (
    88→

    89→      {/* Swipe indicator background */}
    90→      {!hasApplied && swipeX > 0 && (
    91→

    98→          <Check className="h-8 w-8 text-green-600" />
    99→
   100→            {swipeProgress >= 1 ? "Release to apply" : "Swipe to apply"}
   101→
   102→

   103→      )}
   104→
   105→      <Card
   106→        className="cursor-pointer active:scale-[0.98] transition-transform touch-pan-y"
   107→        style={{
   108→          transform: `translateX(${swipeX}px)`,
   109→          transition: swiping ? 'none' : 'transform 0.2s ease-out',
   110→          backgroundColor,
   111→        }}
   112→        onTouchStart={handleTouchStart}
   113→        onTouchMove={handleTouchMove}
   114→        onTouchEnd={handleTouchEnd}
   115→        onClick={() => !swiping && onClick(job)}
   116→      >
   117→        <CardContent className="p-5">
   118→

   119→

   120→

   121→                <Badge variant="secondary" className="text-sm shrink-0 py-1 px-3">
   122→                  {tradeLabelFromId(job.trade)}
   123→                </Badge>
   124→                {hasApplied && (
   125→                  <Badge className="text-sm bg-green-600 shrink-0 py-1 px-3">
   126→                    <Check className="h-3.5 w-3.5 mr-1" />
   127→                    Applied
   128→                  </Badge>
   129→                )}
   130→

   131→
{job.title}

   132→

   133→

   134→
   135→

   136→            {job.suburb && (
   137→
   138→                <MapPin className="h-4 w-4" />
   139→                {job.suburb}
   140→
   141→            )}
   142→            {(job.rate_min || job.rate_max) && (
   143→
   144→                <DollarSign className="h-4 w-4" />
   145→                {job.rate_min && job.rate_max
   146→                  ? `$${job.rate_min}–$${job.rate_max}`
   147→                  : job.rate_min
   148→                  ? `From $${job.rate_min}`
   149→                  : `Up to $${job.rate_max}`}
   150→                {job.rate_type === "hourly" ? "/hr" : "/day"}
   151→
   152→            )}
   153→            {job.start_date && (
   154→
   155→                <Calendar className="h-4 w-4" />
   156→                {new Date(job.start_date).toLocaleDateString("en-AU", {
   157→                  day: "numeric",
   158→                  month: "short",
   159→                })}
   160→
   161→            )}
   162→            {job.workers_needed > 1 && (
   163→
   164→                <HardHat className="h-4 w-4" />
   165→                {job.workers_needed} workers
   166→
   167→            )}
   168→

   169→        </CardContent>
   170→      </Card>
   171→

   172→  );
   173→}
   174→
   175→export default function WorkerJobsPage() {
   176→  const supabase = createClient();
   177→  const [jobs, setJobs] = useState<Job[]>([]);
   178→  const [loading, setLoading] = useState(true);
   179→  const [activeTrade, setActiveTrade] = useState("All");
   180→  const [selectedJob, setSelectedJob] = useState<Job | null>(null);
   181→  const [applying, setApplying] = useState(false);
   182→  const [appliedJobs, setAppliedJobs] = useState<Set<string>>(new Set());
   183→  const [userId, setUserId] = useState<string | null>(null);
   184→  const [currentPage, setCurrentPage] = useState(1);
   185→  const [totalPages, setTotalPages] = useState(1);
   186→  const [totalCount, setTotalCount] = useState(0);
   187→  const ITEMS_PER_PAGE = 10;
   188→
   189→  useEffect(() => {
   190→    async function load() {
   191→      const { data: { user } } = await supabase.auth.getUser();
   192→      if (user) setUserId(user.id);
   193→
   194→      // Fetch active jobs with pagination
   195→      const from = (currentPage - 1) * ITEMS_PER_PAGE;
   196→      const to = from + ITEMS_PER_PAGE - 1;
   197→
   198→      // Get total count first
   199→      const { count } = await supabase
   200→        .from("jobs")
   201→        .select("*", { count: "exact", head: true })
   202→        .eq("status", "active");
   203→
   204→      if (count) {
   205→        setTotalCount(count);
   206→        setTotalPages(Math.ceil(count / ITEMS_PER_PAGE));
   207→      }
   208→
   209→      // Fetch paginated jobs
   210→      const { data } = await supabase
   211→        .from("jobs")
   212→        .select("id, title, trade, suburb, rate_min, rate_max, rate_type, start_date, workers_needed, status, created_at, location")
   213→        .eq("status", "active")
   214→        .order("created_at", { ascending: false })
   215→        .range(from, to);
   216→
   217→      if (data) {
   218→        // Convert PostgreSQL point to JSON format
   219→        const convertedJobs = data.map((job: any) => ({
   220→          ...job,
   221→          location: postgresPointToJson(job.location)
   222→        }));
   223→        setJobs(convertedJobs);
   224→      }
   225→
   226→      // Fetch already-applied jobs
   227→      if (user) {
   228→        const { data: matches } = await supabase
   229→          .from("matches")
   230→          .select("job_id")
   231→          .eq("worker_id", user.id);
   232→
   233→        if (matches) {
   234→          setAppliedJobs(new Set(matches.map((m: { job_id: string }) => m.job_id)));
   235→        }
   236→      }
   237→
   238→      setLoading(false);
   239→    }
   240→    load();
   241→  }, [supabase, currentPage]);
   242→
   243→  const filteredJobs =
   244→    activeTrade === "All"
   245→      ? jobs
   246→      : jobs.filter(
   247→          (j) => tradeLabelFromId(j.trade).toLowerCase() === activeTrade.toLowerCase()
   248→        );
   249→
   250→  async function handleApply(job: Job) {
   251→    if (!userId || appliedJobs.has(job.id)) return;
   252→    setApplying(true);
   253→
   254→    // Haptic feedback (if supported)
   255→    if (navigator.vibrate) {
   256→      navigator.vibrate(50);
   257→    }
   258→
   259→    const { error } = await supabase.from("matches").insert({
   260→      job_id: job.id,
   261→      worker_id: userId,
   262→      status: "applied",
   263→      contractor_action: "pending",
   264→      worker_action: "accepted",
   265→    });
   266→
   267→    if (error) {
   268→      logger.error("Failed to apply for job:", error);
   269→      toast.error("Failed to apply for job. Please try again.");
   270→      setApplying(false);
   271→      return;
   272→    }
   273→
   274→    setAppliedJobs((prev) => new Set(prev).add(job.id));
   275→    toast.success("Application submitted!");
   276→    setApplying(false);
   277→    setSelectedJob(null);
   278→  }
   279→
   280→  if (loading) {
   281→    return (
   282→

   283→        <Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
   284→

   285→    );
   286→  }
   287→
   288→  return (
   289→

   290→
   291→        {/* Trade filters — horizontal scroll */}
   292→

   293→          {TRADE_FILTERS.map((trade) => (
   294→            <button
   295→              key={trade}
   296→              onClick={() => setActiveTrade(trade)}
   297→              className={`shrink-0 rounded-full px-5 py-3 text-base font-semibold transition-all active:scale-95 min-h-[48px] ${
   298→                activeTrade === trade
   299→                  ? "bg-primary text-primary-foreground"
   300→                  : "bg-muted text-muted-foreground"
   301→              }`}
   302→            >
   303→              {trade}
   304→            </button>
   305→          ))}
   306→

   307→
   308→        {/* Swipe hint */}
   309→        {filteredJobs.length > 0 && !loading && (
   310→

   311→

   312→              👉 Swipe right to quick apply • Tap to view details
   313→

   314→

   315→        )}
   316→
   317→        {/* Job list */}
   318→        {filteredJobs.length === 0 ? (
   319→

   320→            <Search className="h-16 w-16 text-muted-foreground/50" />
   321→
No jobs found

   322→

   323→              {activeTrade === "All"
   324→                ? "No jobs posted yet. Check back soon."
   325→                : `No ${activeTrade} jobs right now. Try another trade.`}
   326→

   327→

   328→        ) : (
   329→          <>
   330→

   331→              {filteredJobs.map((job) => (
   332→                <SwipeableJobCard
   333→                  key={job.id}
   334→                  job={job}
   335→                  hasApplied={appliedJobs.has(job.id)}
   336→                  onApply={handleApply}
   337→                  onClick={setSelectedJob}
   338→                />
   339→              ))}
   340→

   341→
   342→            {/* Pagination Controls */}
   343→            {totalPages > 1 && (
   344→

   345→                <Button
   346→                  variant="outline"
   347→                  size="sm"
   348→                  onClick={() => setCurrentPage(prev => Math.max(1, prev - 1))}
   349→                  disabled={currentPage === 1}
   350→                >
   351→                  Previous
   352→                </Button>
   353→
   354→

   355→                  {Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
   356→                    let pageNum;
   357→                    if (totalPages <= 5) {
   358→                      pageNum = i + 1;
   359→                    } else if (currentPage <= 3) {
   360→                      pageNum = i + 1;
   361→                    } else if (currentPage >= totalPages - 2) {
   362→                      pageNum = totalPages - 4 + i;
   363→                    } else {
   364→                      pageNum = currentPage - 2 + i;
   365→                    }
   366→
   367→                    return (
   368→                      <Button
   369→                        key={pageNum}
   370→                        variant={currentPage === pageNum ? "default" : "outline"}
   371→                        size="sm"
   372→                        onClick={() => setCurrentPage(pageNum)}
   373→                        className="min-w-[36px]"
   374→                      >
   375→                        {pageNum}
   376→                      </Button>
   377→                    );
   378→                  })}
   379→

   380→
   381→                <Button
   382→                  variant="outline"
   383→                  size="sm"
   384→                  onClick={() => setCurrentPage(prev => Math.min(totalPages, prev + 1))}
   385→                  disabled={currentPage === totalPages}
   386→                >
   387→                  Next
   388→                </Button>
   389→

   390→            )}
   391→          </>
   392→        )}
   393→
   394→
   395→      {/* Job Detail Sheet (bottom sheet style) */}
   396→      {selectedJob && (
   397→

   398→
 setSelectedJob(null)}
   401→          />
   402→

   403→            {/* Handle */}
   404→

   405→

   406→

   407→
   408→

   409→              <Badge variant="secondary" className="mb-3 text-sm py-1.5 px-3">
   410→                {tradeLabelFromId(selectedJob.trade)}
   411→              </Badge>
   412→
{selectedJob.title}

   413→
   414→

   415→                {selectedJob.suburb && (
   416→
   417→                    <MapPin className="h-5 w-5" />
   418→                    {selectedJob.site_address || selectedJob.suburb}
   419→
   420→                )}
   421→                {(selectedJob.rate_min || selectedJob.rate_max) && (
   422→
   423→                    <DollarSign className="h-5 w-5" />
   424→                    {selectedJob.rate_min && selectedJob.rate_max
   425→                      ? `$${selectedJob.rate_min}–$${selectedJob.rate_max}`
   426→                      : selectedJob.rate_min
   427→                      ? `From $${selectedJob.rate_min}`
   428→                      : `Up to $${selectedJob.rate_max}`}
   429→                    {selectedJob.rate_type === "hourly" ? "/hr" : "/day"}
   430→
   431→                )}
   432→

   433→
   434→

   435→                {selectedJob.start_date && (
   436→
   437→                    <Calendar className="h-5 w-5" />
   438→                    Starts{" "}
   439→                    {new Date(selectedJob.start_date).toLocaleDateString("en-AU", {
   440→                      weekday: "short",
   441→                      day: "numeric",
   442→                      month: "short",
   443→                    })}
   444→
   445→                )}
   446→                {selectedJob.duration && (
   447→                  Duration: {selectedJob.duration}
   448→                )}
   449→                {selectedJob.workers_needed > 1 && (
   450→
   451→                    <HardHat className="h-5 w-5" />
   452→                    {selectedJob.workers_needed} workers needed
   453→
   454→                )}
   455→

   456→
   457→              {selectedJob.description && (
   458→

   459→
About the Job

   460→

   461→                    {selectedJob.description}
   462→

   463→

   464→              )}
   465→
   466→              {selectedJob.certifications_required?.length > 0 && (
   467→

   468→
Required Certs

   469→

   470→                    {selectedJob.certifications_required.map((cert) => (
   471→                      <Badge key={cert} variant="outline" className="text-sm py-1.5 px-3">
   472→                        {cert}
   473→                      </Badge>
   474→                    ))}
   475→

   476→

   477→              )}
   478→

   479→
   480→            {/* Sticky Bottom CTA */}
   481→

   482→

   483→                {appliedJobs.has(selectedJob.id) ? (
   484→                  <Button disabled className="w-full h-16 text-lg font-semibold bg-green-600">
   485→                    <Check className="mr-2 h-6 w-6" />
   486→                    Already Applied
   487→                  </Button>
   488→                ) : (
   489→                  <Button
   490→                    onClick={() => handleApply(selectedJob)}
   491→                    className="w-full h-16 text-lg font-semibold"
   492→                    disabled={applying}
   493→                  >
   494→                    {applying ? (
   495→                      <>
   496→                        <Loader2 className="mr-2 h-6 w-6 animate-spin" />
   497→                        Applying...
   498→                      </>
   499→                    ) : (
   500→                      <>
   501→                        Apply with One Tap
   502→                        <Check className="ml-2 h-6 w-6" />
   503→                      </>
   504→                    )}
   505→                  </Button>
   506→                )}
   507→

   508→                  Always free for workers. No fees ever.
   509→

   510→

   511→

   512→

   513→

   514→      )}
   515→
   516→      <BottomNav userType="worker" />
   517→

   518→  );
   519→}
   520→

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


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

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


     1→import { createClient } from "@/lib/supabase/server";
     2→import { NextRequest, NextResponse } from "next/server";
     3→import { logger } from "@/lib/logger";
     4→import { withCSRFProtection } from "@/lib/csrf";
     5→import { checkRateLimit, getClientIP, createRateLimitResponse } from "@/lib/rate-limit";
     6→
     7→// 20 conversation starts per 15 min window per IP (prevents spam conversation creation)
     8→const START_RATE_LIMIT = 20;
     9→const START_WINDOW_MS = 15 * 60 * 1000;
    10→
    11→const handler = async (request: NextRequest) => {
    12→  try {
    13→    const clientIP = getClientIP(request);
    14→    const rateLimit = checkRateLimit(`messages-start:${clientIP}`, START_RATE_LIMIT, START_WINDOW_MS);
    15→
    16→    if (!rateLimit.allowed) {
    17→      const retryAfter = Math.ceil((rateLimit.resetTime - Date.now()) / 1000);
    18→      return createRateLimitResponse(clientIP, retryAfter);
    19→    }
    20→
    21→    const supabase = await createClient();
    22→
    23→    // Check if user is authenticated
    24→    const { data: { user }, error: authError } = await supabase.auth.getUser();
    25→
    26→    if (authError || !user) {
    27→      return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
    28→    }
    29→
    30→    const { recipientId, initialMessage } = await request.json();
    31→
    32→    if (!recipientId) {
    33→      return NextResponse.json(
    34→        { error: "Recipient ID is required" },
    35→        { status: 400 }
    36→      );
    37→    }
    38→
    39→    // Verify recipient exists
    40→    const { data: recipient, error: recipientError } = await supabase
    41→      .from('profiles')
    42→      .select('id, name')
    43→      .eq('id', recipientId)
    44→      .single();
    45→
    46→    if (recipientError || !recipient) {
    47→      return NextResponse.json(
    48→        { error: "Recipient not found" },
    49→        { status: 404 }
    50→      );
    51→    }
    52→
    53→    // Get or create conversation using the database function
    54→    const { data: conversationId, error: conversationError } = await supabase
    55→      .rpc('get_or_create_conversation', {
    56→        user1_id: user.id,
    57→        user2_id: recipientId
    58→      });
    59→
    60→    if (conversationError) {
    61→      logger.error("Error creating conversation:", conversationError);
    62→      return NextResponse.json(
    63→        { error: "Failed to create conversation" },
    64→        { status: 500 }
    65→      );
    66→    }
    67→
    68→    // If there's an initial message, send it
    69→    if (initialMessage?.trim()) {
    70→      const { error: messageError } = await supabase
    71→        .from('messages')
    72→        .insert({
    73→          conversation_id: conversationId,
    74→          sender_id: user.id,
    75→          content: initialMessage.trim()
    76→        });
    77→
    78→      if (messageError) {
    79→        logger.error("Error sending initial message:", messageError);
    80→        // Don't fail the whole request if message fails
    81→      }
    82→    }
    83→
    84→    return NextResponse.json({
    85→      conversationId,
    86→      message: "Conversation created successfully"
    87→    });
    88→  } catch (error) {
    89→    logger.error("Error in start conversation API:", error);
    90→    return NextResponse.json(
    91→      { error: "Internal server error" },
    92→      { status: 500 }
    93→    );
    94→  }
    95→}
    96→
    97→// Apply CSRF protection to the handler
    98→export const POST = withCSRFProtection(handler);
    99→

<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 the database schema and RLS policies:


     1→-- RateRight v2 Database Schema
     2→-- Run this in the Supabase SQL Editor
     3→
     4→-- ============================================
     5→-- ENUMS
     6→-- ============================================
     7→
     8→CREATE TYPE user_type AS ENUM ('worker', 'contractor');
     9→CREATE TYPE availability_type AS ENUM ('full-time', 'casual', 'weekends', 'flexible');
    10→CREATE TYPE rate_type AS ENUM ('hourly', 'daily');
    11→CREATE TYPE job_status AS ENUM ('draft', 'active', 'filled', 'closed');
    12→CREATE TYPE match_status AS ENUM ('suggested', 'applied', 'accepted', 'rejected', 'hired');
    13→CREATE TYPE action_status AS ENUM ('pending', 'accepted', 'rejected');
    14→CREATE TYPE payment_status AS ENUM ('pending', 'charged', 'refunded');
    15→CREATE TYPE notification_type AS ENUM ('match', 'hire_request', 'hire_confirmed', 'rating', 'job_posted', 'system');
    16→
    17→-- ============================================
    18→-- HELPER FUNCTION: auto-update updated_at
    19→-- ============================================
    20→
    21→CREATE OR REPLACE FUNCTION update_updated_at()
    22→RETURNS trigger AS $$
    23→BEGIN
    24→  NEW.updated_at = now();
    25→  RETURN NEW;
    26→END;
    27→$$ LANGUAGE plpgsql;
    28→
    29→-- ============================================
    30→-- TABLES
    31→-- ============================================
    32→
    33→-- Profiles (extends Supabase Auth)
    34→CREATE TABLE profiles (
    35→  id uuid PRIMARY KEY REFERENCES auth.users ON DELETE CASCADE,
    36→  type user_type NOT NULL,
    37→  name text,
    38→  phone text,
    39→  email text,
    40→  avatar_url text,
    41→  location point,
    42→  suburb text,
    43→  verified boolean DEFAULT false,
    44→  onboarding_completed boolean DEFAULT false,
    45→  created_at timestamptz DEFAULT now(),
    46→  updated_at timestamptz DEFAULT now(),
    47→  ai_generated boolean DEFAULT false
    48→);
    49→
    50→CREATE TRIGGER profiles_updated_at
    51→  BEFORE UPDATE ON profiles
    52→  FOR EACH ROW EXECUTE FUNCTION update_updated_at();
    53→
    54→-- Companies (contractor-specific)
    55→CREATE TABLE companies (
    56→  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
    57→  profile_id uuid NOT NULL UNIQUE REFERENCES profiles ON DELETE CASCADE,
    58→  name text NOT NULL,
    59→  abn text,
    60→  address text,
    61→  industry text,
    62→  size text,
    63→  website text,
    64→  logo_url text,
    65→  ai_research jsonb,
    66→  created_at timestamptz DEFAULT now(),
    67→  updated_at timestamptz DEFAULT now()
    68→);
    69→
    70→CREATE TRIGGER companies_updated_at
    71→  BEFORE UPDATE ON companies
    72→  FOR EACH ROW EXECUTE FUNCTION update_updated_at();
    73→
    74→-- Worker profiles (worker-specific)
    75→CREATE TABLE worker_profiles (
    76→  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
    77→  profile_id uuid NOT NULL UNIQUE REFERENCES profiles ON DELETE CASCADE,
    78→  trades text[] DEFAULT '{}',
    79→  experience_years integer,
    80→  certifications text[] DEFAULT '{}',
    81→  white_card_number text,
    82→  white_card_verified boolean DEFAULT false,
    83→  availability availability_type,
    84→  languages text[] DEFAULT '{}',
    85→  bio text,
    86→  voice_note_url text,
    87→  ai_extracted jsonb,
    88→  created_at timestamptz DEFAULT now(),
    89→  updated_at timestamptz DEFAULT now()
    90→);
    91→
    92→CREATE TRIGGER worker_profiles_updated_at
    93→  BEFORE UPDATE ON worker_profiles
    94→  FOR EACH ROW EXECUTE FUNCTION update_updated_at();
    95→
    96→-- Jobs
    97→CREATE TABLE jobs (
    98→  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
    99→  company_id uuid NOT NULL REFERENCES companies ON DELETE CASCADE,
   100→  title text NOT NULL,
   101→  description text,
   102→  trade text NOT NULL,
   103→  location point,
   104→  suburb text,
   105→  site_address text,
   106→  rate_min decimal,
   107→  rate_max decimal,
   108→  rate_type rate_type DEFAULT 'hourly',
   109→  start_date date,
   110→  duration text,
   111→  workers_needed integer DEFAULT 1,
   112→  certifications_required text[] DEFAULT '{}',
   113→  status job_status DEFAULT 'draft',
   114→  ai_generated boolean DEFAULT false,
   115→  created_at timestamptz DEFAULT now(),
   116→  updated_at timestamptz DEFAULT now()
   117→);
   118→
   119→CREATE TRIGGER jobs_updated_at
   120→  BEFORE UPDATE ON jobs
   121→  FOR EACH ROW EXECUTE FUNCTION update_updated_at();
   122→
   123→-- Matches
   124→CREATE TABLE matches (
   125→  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
   126→  job_id uuid NOT NULL REFERENCES jobs ON DELETE CASCADE,
   127→  worker_id uuid NOT NULL REFERENCES profiles ON DELETE CASCADE,
   128→  status match_status DEFAULT 'suggested',
   129→  match_score decimal,
   130→  ai_summary text,
   131→  contractor_action action_status DEFAULT 'pending',
   132→  worker_action action_status DEFAULT 'pending',
   133→  hired_at timestamptz,
   134→  fee_charged boolean DEFAULT false,
   135→  created_at timestamptz DEFAULT now(),
   136→  updated_at timestamptz DEFAULT now(),
   137→  UNIQUE (job_id, worker_id)
   138→);
   139→
   140→CREATE TRIGGER matches_updated_at
   141→  BEFORE UPDATE ON matches
   142→  FOR EACH ROW EXECUTE FUNCTION update_updated_at();
   143→
   144→-- Ratings
   145→CREATE TABLE ratings (
   146→  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
   147→  match_id uuid NOT NULL REFERENCES matches ON DELETE CASCADE,
   148→  from_id uuid NOT NULL REFERENCES profiles ON DELETE CASCADE,
   149→  to_id uuid NOT NULL REFERENCES profiles ON DELETE CASCADE,
   150→  score integer NOT NULL CHECK (score BETWEEN 1 AND 5),
   151→  comment text,
   152→  created_at timestamptz DEFAULT now(),
   153→  UNIQUE (match_id, from_id)
   154→);
   155→
   156→-- Payments
   157→CREATE TABLE payments (
   158→  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
   159→  match_id uuid NOT NULL REFERENCES matches ON DELETE CASCADE,
   160→  company_id uuid NOT NULL REFERENCES companies ON DELETE CASCADE,
   161→  amount decimal DEFAULT 50.00,
   162→  status payment_status DEFAULT 'pending',
   163→  stripe_payment_id text,
   164→  created_at timestamptz DEFAULT now()
   165→);
   166→
   167→-- Notifications
   168→CREATE TABLE notifications (
   169→  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
   170→  profile_id uuid NOT NULL REFERENCES profiles ON DELETE CASCADE,
   171→  type notification_type NOT NULL,
   172→  title text NOT NULL,
   173→  body text,
   174→  data jsonb,
   175→  read boolean DEFAULT false,
   176→  created_at timestamptz DEFAULT now()
   177→);
   178→
   179→-- ============================================
   180→-- INDEXES
   181→-- ============================================
   182→
   183→CREATE INDEX idx_profiles_type ON profiles (type);
   184→CREATE INDEX idx_profiles_suburb ON profiles (suburb);
   185→
   186→CREATE INDEX idx_companies_profile_id ON companies (profile_id);
   187→CREATE INDEX idx_companies_abn ON companies (abn);
   188→
   189→CREATE INDEX idx_worker_profiles_profile_id ON worker_profiles (profile_id);
   190→CREATE INDEX idx_worker_profiles_trades ON worker_profiles USING gin (trades);
   191→CREATE INDEX idx_worker_profiles_availability ON worker_profiles (availability);
   192→CREATE INDEX idx_worker_profiles_certifications ON worker_profiles USING gin (certifications);
   193→
   194→CREATE INDEX idx_jobs_company_id ON jobs (company_id);
   195→CREATE INDEX idx_jobs_status ON jobs (status);
   196→CREATE INDEX idx_jobs_trade ON jobs (trade);
   197→CREATE INDEX idx_jobs_created_at ON jobs (created_at DESC);
   198→CREATE INDEX idx_jobs_suburb ON jobs (suburb);
   199→
   200→CREATE INDEX idx_matches_job_id ON matches (job_id);

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


find /home/ccuser/the-50-dollar-app/supabase/migrations -name "*.sql" -type f | sort


   200→CREATE INDEX idx_matches_job_id ON matches (job_id);
   201→CREATE INDEX idx_matches_worker_id ON matches (worker_id);
   202→CREATE INDEX idx_matches_status ON matches (status);
   203→
   204→CREATE INDEX idx_ratings_to_id ON ratings (to_id);
   205→CREATE INDEX idx_ratings_from_id ON ratings (from_id);
   206→
   207→CREATE INDEX idx_payments_match_id ON payments (match_id);
   208→CREATE INDEX idx_payments_company_id ON payments (company_id);
   209→
   210→CREATE INDEX idx_notifications_profile_id ON notifications (profile_id);
   211→CREATE INDEX idx_notifications_read ON notifications (profile_id, read);
   212→
   213→-- ============================================
   214→-- ROW LEVEL SECURITY
   215→-- ============================================
   216→
   217→ALTER TABLE profiles ENABLE ROW LEVEL SECURITY;
   218→ALTER TABLE companies ENABLE ROW LEVEL SECURITY;
   219→ALTER TABLE worker_profiles ENABLE ROW LEVEL SECURITY;
   220→ALTER TABLE jobs ENABLE ROW LEVEL SECURITY;
   221→ALTER TABLE matches ENABLE ROW LEVEL SECURITY;
   222→ALTER TABLE ratings ENABLE ROW LEVEL SECURITY;
   223→ALTER TABLE payments ENABLE ROW LEVEL SECURITY;
   224→ALTER TABLE notifications ENABLE ROW LEVEL SECURITY;
   225→
   226→-- Profiles: users can read all profiles, update their own
   227→CREATE POLICY "Public profiles are viewable by everyone"
   228→  ON profiles FOR SELECT
   229→  USING (true);
   230→
   231→CREATE POLICY "Users can update their own profile"
   232→  ON profiles FOR UPDATE
   233→  USING (auth.uid() = id);
   234→
   235→CREATE POLICY "Users can insert their own profile"
   236→  ON profiles FOR INSERT
   237→  WITH CHECK (auth.uid() = id);
   238→
   239→-- Companies: viewable by everyone, editable by owner
   240→CREATE POLICY "Companies are viewable by everyone"
   241→  ON companies FOR SELECT
   242→  USING (true);
   243→
   244→CREATE POLICY "Contractors can insert their own company"
   245→  ON companies FOR INSERT
   246→  WITH CHECK (profile_id = auth.uid());
   247→
   248→CREATE POLICY "Contractors can update their own company"
   249→  ON companies FOR UPDATE
   250→  USING (profile_id = auth.uid());
   251→
   252→-- Worker profiles: viewable by everyone, editable by owner
   253→CREATE POLICY "Worker profiles are viewable by everyone"
   254→  ON worker_profiles FOR SELECT
   255→  USING (true);
   256→
   257→CREATE POLICY "Workers can insert their own profile"
   258→  ON worker_profiles FOR INSERT
   259→  WITH CHECK (profile_id = auth.uid());
   260→
   261→CREATE POLICY "Workers can update their own profile"
   262→  ON worker_profiles FOR UPDATE
   263→  USING (profile_id = auth.uid());
   264→
   265→-- Jobs: viewable by everyone, editable by company owner
   266→CREATE POLICY "Jobs are viewable by everyone"
   267→  ON jobs FOR SELECT
   268→  USING (true);
   269→
   270→CREATE POLICY "Contractors can insert jobs for their company"
   271→  ON jobs FOR INSERT
   272→  WITH CHECK (
   273→    company_id IN (
   274→      SELECT id FROM companies WHERE profile_id = auth.uid()
   275→    )
   276→  );
   277→
   278→CREATE POLICY "Contractors can update their own jobs"
   279→  ON jobs FOR UPDATE
   280→  USING (
   281→    company_id IN (
   282→      SELECT id FROM companies WHERE profile_id = auth.uid()
   283→    )
   284→  );
   285→
   286→-- Matches: viewable by involved parties
   287→CREATE POLICY "Users can view their own matches"
   288→  ON matches FOR SELECT
   289→  USING (
   290→    worker_id = auth.uid() OR
   291→    job_id IN (
   292→      SELECT j.id FROM jobs j
   293→      JOIN companies c ON j.company_id = c.id
   294→      WHERE c.profile_id = auth.uid()
   295→    )
   296→  );
   297→
   298→CREATE POLICY "System can insert matches"
   299→  ON matches FOR INSERT
   300→  WITH CHECK (true);
   301→
   302→CREATE POLICY "Involved parties can update matches"
   303→  ON matches FOR UPDATE
   304→  USING (
   305→    worker_id = auth.uid() OR
   306→    job_id IN (
   307→      SELECT j.id FROM jobs j
   308→      JOIN companies c ON j.company_id = c.id
   309→      WHERE c.profile_id = auth.uid()
   310→    )
   311→  );
   312→
   313→-- Ratings: viewable by everyone, insertable by match participants
   314→CREATE POLICY "Ratings are viewable by everyone"
   315→  ON ratings FOR SELECT
   316→  USING (true);
   317→
   318→CREATE POLICY "Match participants can insert ratings"
   319→  ON ratings FOR INSERT
   320→  WITH CHECK (from_id = auth.uid());
   321→
   322→-- Payments: viewable by company owner
   323→CREATE POLICY "Company owners can view their payments"
   324→  ON payments FOR SELECT
   325→  USING (
   326→    company_id IN (
   327→      SELECT id FROM companies WHERE profile_id = auth.uid()
   328→    )
   329→  );
   330→
   331→CREATE POLICY "System can insert payments"
   332→  ON payments FOR INSERT
   333→  WITH CHECK (true);
   334→
   335→-- Notifications: users can only see their own
   336→CREATE POLICY "Users can view their own notifications"
   337→  ON notifications FOR SELECT
   338→  USING (profile_id = auth.uid());
   339→
   340→CREATE POLICY "System can insert notifications"
   341→  ON notifications FOR INSERT
   342→  WITH CHECK (true);
   343→
   344→CREATE POLICY "Users can update their own notifications"
   345→  ON notifications FOR UPDATE
   346→  USING (profile_id = auth.uid());
   347→
   348→-- ============================================
   349→-- FUNCTIONS
   350→-- ============================================
   351→
   352→-- Auto-create profile on signup
   353→-- Fixed: Now handles invalid/missing user_type gracefully (defaults to 'worker')
   354→CREATE OR REPLACE FUNCTION public.handle_new_user()
   355→RETURNS trigger AS $$
   356→DECLARE
   357→    v_user_type text;
   358→    v_valid_type user_type;
   359→BEGIN
   360→    -- Get the user_type from metadata
   361→    v_user_type := NEW.raw_user_meta_data->>'type';
   362→
   363→    -- Validate the user_type before casting (defaults to 'worker' if invalid)
   364→    CASE v_user_type
   365→        WHEN 'worker' THEN
   366→            v_valid_type := 'worker'::user_type;
   367→        WHEN 'contractor' THEN
   368→            v_valid_type := 'contractor'::user_type;
   369→        ELSE
   370→            v_valid_type := 'worker'::user_type;
   371→    END CASE;
   372→
   373→    INSERT INTO public.profiles (id, email, type)
   374→    VALUES (
   375→        NEW.id,
   376→        NEW.email,
   377→        v_valid_type
   378→    );
   379→
   380→    RETURN NEW;
   381→EXCEPTION
   382→    WHEN OTHERS THEN
   383→        RAISE WARNING 'handle_new_user() failed for user %: %', NEW.id, SQLERRM;
   384→        RAISE;
   385→END;
   386→$$ LANGUAGE plpgsql SECURITY DEFINER;
   387→
   388→CREATE OR REPLACE TRIGGER on_auth_user_created
   389→  AFTER INSERT ON auth.users
   390→  FOR EACH ROW EXECUTE FUNCTION public.handle_new_user();
   391→
   392→-- Average rating helper
   393→CREATE OR REPLACE FUNCTION public.get_average_rating(user_id uuid)
   394→RETURNS decimal AS $$
   395→  SELECT COALESCE(AVG(score)::decimal, 0)
   396→  FROM ratings
   397→  WHERE to_id = user_id;
   398→$$ LANGUAGE sql STABLE;
   399→
   400→-- Rating count helper
   401→CREATE OR REPLACE FUNCTION public.get_rating_count(user_id uuid)
   402→RETURNS integer AS $$
   403→  SELECT COUNT(*)::integer
   404→  FROM ratings
   405→  WHERE to_id = user_id;
   406→$$ LANGUAGE sql STABLE;
   407→
   408→-- ============================================
   409→-- MATCHING FUNCTIONS (DB-SIDE SCORING)
   410→-- ============================================
   411→
   412→-- RPC FUNCTION: find_matching_workers
   413→-- Calculates match scores in the database for scalability
   414→-- Scoring: trade (40%), availability (20%), experience (15%),
   415→--          certifications (15%), location (10%), ratings (10%)
   416→CREATE OR REPLACE FUNCTION find_matching_workers(
   417→  p_job_id uuid,
   418→  p_limit integer DEFAULT 20,
   419→  p_min_score integer DEFAULT 20
   420→)
   421→RETURNS TABLE (
   422→  worker_id uuid,
   423→  score integer,
   424→  summary text,
   425→  reasons text[],
   426→  profile jsonb
   427→) AS $$
   428→DECLARE
   429→  v_job_record RECORD;
   430→  v_job_location point;
   431→BEGIN
   432→  -- Get job details once
   433→  SELECT
   434→    j.id,
   435→    j.trade,
   436→    j.certifications_required,
   437→    j.start_date,
   438→    j.duration,
   439→    j.suburb,
   440→    j.location,
   441→    j.company_id
   442→  INTO v_job_record
   443→  FROM jobs j
   444→  WHERE j.id = p_job_id AND j.status = 'active';
   445→
   446→  -- Return empty if job not found or not active
   447→  IF v_job_record IS NULL THEN
   448→    RETURN;
   449→  END IF;
   450→
   451→  v_job_location := v_job_record.location;
   452→
   453→  RETURN QUERY
   454→  WITH
   455→  -- Get existing matches to exclude
   456→  existing_matches AS (
   457→    SELECT m.worker_id AS excluded_worker_id
   458→    FROM matches m
   459→    WHERE m.job_id = p_job_id
   460→  ),
   461→  -- Calculate average ratings per worker
   462→  worker_ratings AS (
   463→    SELECT
   464→      r.to_id AS rating_worker_id,
   465→      AVG(r.score)::decimal AS avg_rating,
   466→      COUNT(*)::integer AS rating_count
   467→    FROM ratings r
   468→    GROUP BY r.to_id
   469→  ),
   470→  -- Score each worker
   471→  scored_workers AS (
   472→    SELECT
   473→      p.id AS worker_profile_id,
   474→      p.name AS worker_name,
   475→      p.suburb AS worker_suburb,
   476→      p.location AS worker_location,
   477→      wp.trades AS worker_trades,
   478→      wp.availability AS worker_availability,
   479→      wp.experience_years,
   480→      wp.certifications AS worker_certs,
   481→      wp.bio,
   482→      wp.white_card_verified,
   483→      -- Trade match score (40 points max)
   484→      CASE
   485→        WHEN v_job_record.trade = ANY(wp.trades) THEN 40
   486→        WHEN 'Labourer' = ANY(wp.trades) THEN 10
   487→        -- Related trade groups
   488→        WHEN v_job_record.trade IN ('Steel Fixer', 'Formworker', 'Concreter', 'Scaffolder')
   489→          AND wp.trades && ARRAY['Steel Fixer', 'Formworker', 'Concreter', 'Scaffolder']::text[] THEN 15
   490→        WHEN v_job_record.trade IN ('Painter', 'Tiler', 'Plasterer')
   491→          AND wp.trades && ARRAY['Painter', 'Tiler', 'Plasterer']::text[] THEN 15
   492→        WHEN v_job_record.trade IN ('Electrician', 'Plumber')
   493→          AND wp.trades && ARRAY['Electrician', 'Plumber']::text[] THEN 15
   494→        WHEN v_job_record.trade IN ('Crane Operator', 'Excavator Operator')
   495→          AND wp.trades && ARRAY['Crane Operator', 'Excavator Operator']::text[] THEN 15
   496→        WHEN v_job_record.trade IN ('Bricklayer', 'Roofer', 'Welder')
   497→          AND wp.trades && ARRAY['Bricklayer', 'Roofer', 'Welder']::text[] THEN 15
   498→        ELSE 0
   499→      END AS trade_score,
   500→      -- Availability score (20 points max)
   501→      CASE wp.availability
   502→        WHEN 'flexible' THEN 20
   503→        WHEN 'full-time' THEN 18
   504→        WHEN 'casual' THEN 12
   505→        WHEN 'weekends' THEN 8
   506→        ELSE 0
   507→      END AS availability_score,
   508→      -- Experience score (15 points max)
   509→      CASE
   510→        WHEN wp.experience_years >= 10 THEN 15
   511→        WHEN wp.experience_years >= 7 THEN 13
   512→        WHEN wp.experience_years >= 4 THEN 11
   513→        WHEN wp.experience_years >= 2 THEN 8
   514→        WHEN wp.experience_years >= 1 THEN 5
   515→        WHEN wp.experience_years IS NOT NULL THEN 2
   516→        ELSE 0
   517→      END AS experience_score,
   518→      -- Certifications score (15 points max)
   519→      CASE
   520→        WHEN array_length(v_job_record.certifications_required, 1) IS NULL
   521→          OR array_length(v_job_record.certifications_required, 1) = 0 THEN 15
   522→        ELSE
   523→          ROUND(
   524→            (
   525→              COALESCE(
   526→                array_length(
   527→                  ARRAY(
   528→                    SELECT unnest(v_job_record.certifications_required)
   529→                    INTERSECT
   530→                    SELECT unnest(wp.certifications)
   531→                  ),
   532→                  1
   533→                ),
   534→                0
   535→              )::decimal /
   536→              array_length(v_job_record.certifications_required, 1)
   537→            ) * 15
   538→          )
   539→      END AS cert_score,
   540→      -- Location score (10 points max) - uses point distance
   541→      CASE
   542→        WHEN v_job_location IS NULL OR p.location IS NULL THEN 0
   543→        WHEN p.location <-> v_job_location < 0.01 THEN 10  -- Same area (~1km)
   544→        WHEN p.location <-> v_job_location < 0.05 THEN 7   -- Nearby (~5km)
   545→        WHEN p.location <-> v_job_location < 0.1 THEN 5    -- Same region (~10km)
   546→        WHEN p.suburb = v_job_record.suburb THEN 10        -- Exact suburb match (fallback)
   547→        WHEN p.suburb ILIKE '%' || v_job_record.suburb || '%'
   548→          OR v_job_record.suburb ILIKE '%' || p.suburb || '%' THEN 7
   549→        ELSE 0
   550→      END AS location_score,
   551→      -- Rating score (10 points max)
   552→      COALESCE(
   553→        ROUND((wr.avg_rating / 5.0) * 10),
   554→        0
   555→      ) AS rating_score,
   556→      wr.avg_rating,
   557→      wr.rating_count
   558→    FROM profiles p
   559→    INNER JOIN worker_profiles wp ON wp.profile_id = p.id
   560→    LEFT JOIN worker_ratings wr ON wr.rating_worker_id = p.id
   561→    WHERE
   562→      p.onboarding_completed = true
   563→      AND p.type = 'worker'
   564→      AND p.id NOT IN (SELECT excluded_worker_id FROM existing_matches)
   565→  ),
   566→  -- Calculate total score and build reasons
   567→  final_scores AS (
   568→    SELECT
   569→      sw.*,
   570→      (sw.trade_score + sw.availability_score + sw.experience_score +
   571→       sw.cert_score + sw.location_score + sw.rating_score) AS total_score,
   572→      -- Build reasons array
   573→      ARRAY_REMOVE(ARRAY[
   574→        CASE WHEN sw.trade_score = 40 THEN 'Trades as ' || v_job_record.trade
   575→             WHEN sw.trade_score = 15 THEN 'Related trade experience'
   576→             WHEN sw.trade_score = 10 THEN 'General labourer available'
   577→             ELSE NULL END,
   578→        CASE WHEN sw.availability_score > 0 THEN 'Available ' || sw.worker_availability ELSE NULL END,
   579→        CASE WHEN sw.experience_score > 0 THEN sw.experience_years || '+ years experience' ELSE NULL END,
   580→        CASE WHEN sw.cert_score > 0 AND array_length(v_job_record.certifications_required, 1) > 0
   581→             THEN 'Has required certifications' ELSE NULL END,
   582→        CASE WHEN sw.location_score > 0 THEN 'Based in ' || sw.worker_suburb ELSE NULL END,
   583→        CASE WHEN sw.rating_score >= 8 THEN ROUND(sw.avg_rating::numeric, 1) || ' star rating' ELSE NULL END
   584→      ], NULL) AS match_reasons
   585→    FROM scored_workers sw
   586→  )
   587→  SELECT
   588→    fs.worker_profile_id,
   589→    LEAST(fs.total_score, 100) AS score,
   590→    array_to_string(fs.match_reasons[:3], '. ') || '.' AS summary,
   591→    fs.match_reasons AS reasons,
   592→    jsonb_build_object(
   593→      'id', fs.worker_profile_id,
   594→      'name', fs.worker_name,
   595→      'suburb', fs.worker_suburb,
   596→      'trades', fs.worker_trades,
   597→      'experience_years', fs.experience_years,
   598→      'availability', fs.worker_availability,
   599→      'certifications', fs.worker_certs,
   600→      'bio', fs.bio,
   601→      'white_card_verified', fs.white_card_verified,
   602→      'avg_rating', COALESCE(fs.avg_rating, 0),
   603→      'rating_count', COALESCE(fs.rating_count, 0)
   604→    ) AS profile
   605→  FROM final_scores fs
   606→  WHERE fs.total_score >= p_min_score
   607→  ORDER BY fs.total_score DESC, fs.experience_years DESC NULLS LAST
   608→  LIMIT p_limit;
   609→END;
   610→$$ LANGUAGE plpgsql SECURITY DEFINER;
   611→
   612→-- Grant execute permission to authenticated users
   613→GRANT EXECUTE ON FUNCTION find_matching_workers(uuid, integer, integer) TO authenticated;
   614→GRANT EXECUTE ON FUNCTION find_matching_workers(uuid, integer, integer) TO service_role;
   615→
   616→-- ============================================
   617→-- MATCHING PERFORMANCE INDEXES
   618→-- ============================================
   619→
   620→-- GiST index for efficient geospatial distance calculations on worker locations
   621→CREATE INDEX IF NOT EXISTS idx_profiles_location ON profiles USING gist (location) WHERE type = 'worker';
   622→
   623→-- Covering index for worker profile lookups during matching
   624→CREATE INDEX IF NOT EXISTS idx_worker_profiles_lookup ON worker_profiles (profile_id, availability, experience_years) INCLUDE (trades, certifications, bio);
   625→

<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→-- Deep audit fixes: search_path + RLS policies
     2→-- Applied directly to production via Management API on 2026-02-08
     3→-- This migration file documents what was changed for version control.
     4→
     5→-- ============================================================
     6→-- 1. ADD SET search_path TO ALL PUBLIC FUNCTIONS
     7→--    Prevents search_path injection attacks.
     8→--    (handle_new_user already had it; these 6 were missing)
     9→-- ============================================================
    10→
    11→-- get_average_rating: already recreated with SET search_path = public
    12→-- get_conversation_previews: already recreated with SET search_path = public
    13→-- get_or_create_conversation: already recreated with SET search_path = public
    14→-- get_rating_count: already recreated with SET search_path = public
    15→-- update_updated_at: already recreated with SET search_path = public
    16→-- validate_payment_creation: already recreated with SET search_path = public
    17→
    18→-- ============================================================
    19→-- 2. FIX notifications INSERT POLICY
    20→--    Was: any authenticated user could insert notifications for ANY user
    21→--    Now: authenticated users can only insert notifications for OTHER users
    22→-- ============================================================
    23→
    24→DROP POLICY IF EXISTS "System can insert notifications" ON public.notifications;
    25→CREATE POLICY "Authenticated users can insert notifications" ON public.notifications
    26→  FOR INSERT TO authenticated
    27→  WITH CHECK (profile_id != auth.uid());
    28→
    29→-- ============================================================
    30→-- 3. ADD MISSING DELETE POLICIES
    31→--    All 11 tables were missing DELETE policies.
    32→--    Adding DELETE for tables where users legitimately need it.
    33→--    (payments, ratings, profiles, worker_profiles, companies,
    34→--     conversations, conversation_participants intentionally omitted)
    35→-- ============================================================
    36→
    37→CREATE POLICY "Users can delete their own notifications" ON public.notifications
    38→  FOR DELETE TO authenticated
    39→  USING (profile_id = auth.uid());
    40→
    41→CREATE POLICY "Users can delete their own messages" ON public.messages
    42→  FOR DELETE TO authenticated
    43→  USING (sender_id = auth.uid());
    44→
    45→CREATE POLICY "Contractors can delete their own jobs" ON public.jobs
    46→  FOR DELETE TO authenticated
    47→  USING (company_id IN (SELECT id FROM public.companies WHERE profile_id = auth.uid()));
    48→
    49→CREATE POLICY "Workers can withdraw their own applications" ON public.matches
    50→  FOR DELETE TO authenticated
    51→  USING (worker_id = auth.uid() AND status IN ('applied', 'suggested'));
    52→

<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 the messaging routes and API endpoints for data exposure:


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

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


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

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


find /home/ccuser/the-50-dollar-app/src/app -type f -name "page.tsx" -o -name "route.ts" | grep -E "(profile|ratings)" | head -10


     1→import { NextRequest, NextResponse } from "next/server";
     2→import { createClient } from "@/lib/supabase/server";
     3→
     4→// Simple logger fallback
     5→const logger = {
     6→  error: (...args: any[]) => console.error("[ratings-api]", ...args),
     7→  warn: (...args: any[]) => console.warn("[ratings-api]", ...args),
     8→  info: (...args: any[]) => console.info("[ratings-api]", ...args),
     9→};
    10→
    11→export async function GET(request: NextRequest) {
    12→  try {
    13→    const supabase = await createClient();
    14→
    15→    const { data: { user } } = await supabase.auth.getUser();
    16→
    17→    if (!user) {
    18→      return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
    19→    }
    20→
    21→    const { searchParams } = new URL(request.url);
    22→    const userId = searchParams.get("userId");
    23→
    24→    if (!userId) {
    25→      return NextResponse.json({ error: "userId is required" }, { status: 400 });
    26→    }
    27→
    28→    // Fetch ratings with profile information
    29→    const { data: ratings, error } = await supabase
    30→      .from("ratings")
    31→      .select(`
    32→        *,
    33→        from_profile:profiles!ratings_from_id_fkey(
    34→          id,
    35→          name,
    36→          avatar_url
    37→        )
    38→      `)
    39→      .eq("to_id", userId)
    40→      .order("created_at", { ascending: false });
    41→
    42→    if (error) {
    43→      console.error("Error fetching ratings:", error);
    44→      return NextResponse.json({ error: error.message }, { status: 500 });
    45→    }
    46→
    47→    // Calculate average rating
    48→    const averageRating = ratings?.length
    49→      ? ratings.reduce((sum, r) => sum + r.score, 0) / ratings.length
    50→      : 0;
    51→
    52→    return NextResponse.json({
    53→      ratings: ratings || [],
    54→      averageRating,
    55→      totalRatings: ratings?.length || 0
    56→    });
    57→  } catch (error) {
    58→    console.error("Unexpected error in ratings API:", error);
    59→    return NextResponse.json(
    60→      { error: "Internal server error" },
    61→      { status: 500 }
    62→    );
    63→  }
    64→}

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