Fix the following data layer issues in /home/ccuser/the-50-dollar-app. MAKE ALL EDITS - do not just report, actually edit the files.

1. **Fix notification subscription filter** in `/home/ccuser/the-50-dollar-app/src/hooks/use-notifications.ts`:
   - Find the `useNotifications` hook's realtime subscription (around lines 149-168)
   - Add a filter to the subscription so it only listens for notifications belonging to the current user. Look for the `.on('postgres_changes', { event: '*', schema: 'public', table: 'notifications' }` and add `filter: 'profile_id=eq.' + userId` to the subscription options
   - Do the same for the `useUnreadCount` subscription (around lines 276-295)

2. **Remove test file from production** - Delete `/home/ccuser/the-50-dollar-app/src/lib/voice-complete-test.ts` using the Bash tool

3. **Create a Supabase migration to fix SECURITY DEFINER functions** - Create a new migration file at `/home/ccuser/the-50-dollar-app/supabase/migrations/20260210100000_fix_security_definer_search_path.sql` with content that:
   - Recreates `find_matching_workers` function with `SET search_path = public` added
   - Recreates ABN cache functions (`get_cached_abn`, `get_abn_cache_stats`, `increment_abn_lookup`, `cleanup_expired_abn_cache`) with `SET search_path = public`
   - Drops the payment DELETE policy: `DROP POLICY IF EXISTS "Company owners can delete their payments" ON payments;`
   - Restricts match deletion to only non-hired matches: Drop the current unrestricted policy and recreate with `USING (... AND status NOT IN ('hired'))`

   NOTE: To get the function signatures, read the original migration files first:
   - `/home/ccuser/the-50-dollar-app/supabase/migrations/20260210000002_add_display_name.sql` for find_matching_workers
   - `/home/ccuser/the-50-dollar-app/supabase/migrations/20260210000001_create_abn_cache_table.sql` for ABN cache functions
   - `/home/ccuser/the-50-dollar-app/supabase/migrations/20260209000003_add_delete_policies.sql` for delete policies

Read each file before making edits.


I'll start by reading all the files I need to understand before making edits.


     1→"use client";
     2→
     3→import { useState, useEffect, useCallback, useRef } from "react";
     4→import { createClient } from "@/lib/supabase/client";
     5→import type { Notification } from "@/lib/types";
     6→import type { RealtimeChannel } from "@supabase/supabase-js";
     7→
     8→interface UseNotificationsOptions {
     9→  limit?: number;
    10→  unreadOnly?: boolean;
    11→}
    12→
    13→interface UseNotificationsReturn {
    14→  notifications: Notification[];
    15→  unreadCount: number;
    16→  loading: boolean;
    17→  error: Error | null;
    18→  markAsRead: (id: string) => Promise<void>;
    19→  markAllAsRead: () => Promise<void>;
    20→  refetch: () => Promise<void>;
    21→}
    22→
    23→/**
    24→ * Hook to fetch and manage notifications from Supabase
    25→ *
    26→ * Features:
    27→ * - Fetches notifications for the current user
    28→ * - Real-time updates via Supabase subscriptions
    29→ * - Mark individual or all notifications as read
    30→ * - Unread count tracking
    31→ *
    32→ * @example
    33→ * ```tsx
    34→ * const { notifications, unreadCount, markAsRead } = useNotifications({ limit: 10 });
    35→ * ```
    36→ */
    37→export function useNotifications(options: UseNotificationsOptions = {}): UseNotificationsReturn {
    38→  const { limit = 50, unreadOnly = false } = options;
    39→  const [notifications, setNotifications] = useState<Notification[]>([]);
    40→  const [loading, setLoading] = useState(true);
    41→  const [error, setError] = useState<Error | null>(null);
    42→  const channelRef = useRef<RealtimeChannel | null>(null);
    43→  const supabase = createClient();
    44→
    45→  const fetchNotifications = useCallback(async () => {
    46→    try {
    47→      setLoading(true);
    48→      setError(null);
    49→
    50→      const { data: { user } } = await supabase.auth.getUser();
    51→      if (!user) {
    52→        setNotifications([]);
    53→        setLoading(false);
    54→        return;
    55→      }
    56→
    57→      let query = supabase
    58→        .from("notifications")
    59→        .select("id, profile_id, type, title, body, data, read, created_at")
    60→        .eq("profile_id", user.id)
    61→        .order("created_at", { ascending: false });
    62→
    63→      if (unreadOnly) {
    64→        query = query.eq("read", false);
    65→      }
    66→
    67→      if (limit) {
    68→        query = query.limit(limit);
    69→      }
    70→
    71→      const { data, error: fetchError } = await query;
    72→
    73→      if (fetchError) {
    74→        throw new Error(fetchError.message);
    75→      }
    76→
    77→      setNotifications((data as Notification[]) ?? []);
    78→    } catch (err) {
    79→      setError(err instanceof Error ? err : new Error("Failed to fetch notifications"));
    80→    } finally {
    81→      setLoading(false);
    82→    }
    83→  }, [supabase, limit, unreadOnly]);
    84→
    85→  // Calculate unread count from notifications
    86→  const unreadCount = notifications.filter((n) => !n.read).length;
    87→
    88→  // Mark a single notification as read
    89→  const markAsRead = useCallback(async (id: string) => {
    90→    try {
    91→      const { error: updateError } = await supabase
    92→        .from("notifications")
    93→        .update({ read: true })
    94→        .eq("id", id);
    95→
    96→      if (updateError) {
    97→        throw new Error(updateError.message);
    98→      }
    99→
   100→      // Optimistically update local state
   101→      setNotifications((prev) =>
   102→        prev.map((n) => (n.id === id ? { ...n, read: true } : n))
   103→      );
   104→    } catch (err) {
   105→      throw err;
   106→    }
   107→  }, [supabase]);
   108→
   109→  // Mark all notifications as read
   110→  const markAllAsRead = useCallback(async () => {
   111→    try {
   112→      const { data: { user } } = await supabase.auth.getUser();
   113→      if (!user) return;
   114→
   115→      const { error: updateError } = await supabase
   116→        .from("notifications")
   117→        .update({ read: true })
   118→        .eq("profile_id", user.id)
   119→        .eq("read", false);
   120→
   121→      if (updateError) {
   122→        throw new Error(updateError.message);
   123→      }
   124→
   125→      // Optimistically update local state
   126→      setNotifications((prev) => prev.map((n) => ({ ...n, read: true })));
   127→    } catch (err) {
   128→      throw err;
   129→    }
   130→  }, [supabase]);
   131→
   132→  // Refetch notifications
   133→  const refetch = useCallback(async () => {
   134→    await fetchNotifications();
   135→  }, [fetchNotifications]);
   136→
   137→  // Initial fetch
   138→  useEffect(() => {
   139→    fetchNotifications();
   140→  }, [fetchNotifications]);
   141→
   142→  // Subscribe to real-time updates
   143→  useEffect(() => {
   144→    // Clean up previous channel if it exists
   145→    if (channelRef.current) {
   146→      supabase.removeChannel(channelRef.current);
   147→    }
   148→
   149→    const channel = supabase
   150→      .channel(`notifications-${Date.now()}`)
   151→      .on(
   152→        "postgres_changes",
   153→        {
   154→          event: "INSERT",
   155→          schema: "public",
   156→          table: "notifications",
   157→        },
   158→        (payload) => {
   159→          // Add new notification to the list
   160→          const newNotification = payload.new as Notification;
   161→          setNotifications((prev) => {
   162→            // Avoid duplicates
   163→            if (prev.some((n) => n.id === newNotification.id)) {
   164→              return prev;
   165→            }
   166→            return [newNotification, ...prev].slice(0, limit);
   167→          });
   168→        }
   169→      )
   170→      .on(
   171→        "postgres_changes",
   172→        {
   173→          event: "UPDATE",
   174→          schema: "public",
   175→          table: "notifications",
   176→        },
   177→        (payload) => {
   178→          // Update existing notification
   179→          const updatedNotification = payload.new as Notification;
   180→          setNotifications((prev) =>
   181→            prev.map((n) => (n.id === updatedNotification.id ? updatedNotification : n))
   182→          );
   183→        }
   184→      )
   185→      .on(
   186→        "postgres_changes",
   187→        {
   188→          event: "DELETE",
   189→          schema: "public",
   190→          table: "notifications",
   191→        },
   192→        (payload) => {
   193→          // Remove deleted notification
   194→          const deletedId = payload.old.id;
   195→          setNotifications((prev) => prev.filter((n) => n.id !== deletedId));
   196→        }
   197→      )
   198→      .subscribe();
   199→
   200→    channelRef.current = channel;
   201→
   202→    return () => {
   203→      if (channelRef.current) {
   204→        supabase.removeChannel(channelRef.current);
   205→      }
   206→    };
   207→  }, [supabase, limit]);
   208→
   209→  return {
   210→    notifications,
   211→    unreadCount,
   212→    loading,
   213→    error,
   214→    markAsRead,
   215→    markAllAsRead,
   216→    refetch,
   217→  };
   218→}
   219→
   220→/**
   221→ * Hook to get only the unread notification count
   222→ * Lightweight version for header/badge use
   223→ *
   224→ * @example
   225→ * ```tsx
   226→ * const { count } = useUnreadCount();
   227→ * ```
   228→ */
   229→export function useUnreadCount(): { count: number; loading: boolean; error: Error | null } {
   230→  const [count, setCount] = useState(0);
   231→  const [loading, setLoading] = useState(true);
   232→  const [error, setError] = useState<Error | null>(null);
   233→  const channelRef = useRef<RealtimeChannel | null>(null);
   234→  const supabase = createClient();
   235→
   236→  const fetchCount = useCallback(async () => {
   237→    try {
   238→      const { data: { user } } = await supabase.auth.getUser();
   239→      if (!user) {
   240→        setCount(0);
   241→        setLoading(false);
   242→        return;
   243→      }
   244→
   245→      const { count: unreadCount, error: countError } = await supabase
   246→        .from("notifications")
   247→        .select("*", { count: "exact", head: true })
   248→        .eq("profile_id", user.id)
   249→        .eq("read", false);
   250→
   251→      if (countError) {
   252→        throw new Error(countError.message);
   253→      }
   254→
   255→      setCount(unreadCount ?? 0);
   256→      setError(null);
   257→    } catch (err) {
   258→      setError(err instanceof Error ? err : new Error("Failed to fetch unread count"));
   259→    } finally {
   260→      setLoading(false);
   261→    }
   262→  }, [supabase]);
   263→
   264→  useEffect(() => {
   265→    fetchCount();
   266→  }, [fetchCount]);
   267→
   268→  // Subscribe to real-time updates
   269→  useEffect(() => {
   270→    // Clean up previous channel if it exists
   271→    if (channelRef.current) {
   272→      supabase.removeChannel(channelRef.current);
   273→    }
   274→
   275→    const channel = supabase
   276→      .channel(`unread-count-${Date.now()}`)
   277→      .on(
   278→        "postgres_changes",
   279→        {
   280→          event: "INSERT",
   281→          schema: "public",
   282→          table: "notifications",
   283→        },
   284→        () => fetchCount()
   285→      )
   286→      .on(
   287→        "postgres_changes",
   288→        {
   289→          event: "UPDATE",
   290→          schema: "public",
   291→          table: "notifications",
   292→        },
   293→        () => fetchCount()
   294→      )
   295→      .subscribe();
   296→
   297→    channelRef.current = channel;
   298→
   299→    return () => {
   300→      if (channelRef.current) {
   301→        supabase.removeChannel(channelRef.current);
   302→      }
   303→    };
   304→  }, [supabase, fetchCount]);
   305→
   306→  return { count, loading, error };
   307→}
   308→

<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→-- Add display_name column to profiles for anonymous pre-hire worker identity
     2→ALTER TABLE profiles ADD COLUMN IF NOT EXISTS display_name text;
     3→
     4→-- Drop and recreate get_conversation_previews (return type changed)
     5→DROP FUNCTION IF EXISTS get_conversation_previews(uuid, integer, integer);
     6→CREATE OR REPLACE FUNCTION get_conversation_previews(
     7→  user_id uuid,
     8→  page_limit integer DEFAULT 20,
     9→  page_offset integer DEFAULT 0
    10→)
    11→RETURNS TABLE (
    12→  conversation_id uuid,
    13→  other_participant_name text,
    14→  other_participant_avatar text,
    15→  last_message text,
    16→  last_message_time timestamptz,
    17→  unread_count bigint,
    18→  other_participant_is_online boolean,
    19→  other_participant_display_name text,
    20→  other_participant_type text
    21→) AS $$
    22→BEGIN
    23→  RETURN QUERY
    24→  SELECT
    25→    c.id as conversation_id,
    26→    p.name as other_participant_name,
    27→    p.avatar_url as other_participant_avatar,
    28→    last_msg.content as last_message,
    29→    last_msg.created_at as last_message_time,
    30→    COALESCE(unread.unread_count, 0) as unread_count,
    31→    COALESCE(p.last_seen_at > (now() - interval '5 minutes'), false) as other_participant_is_online,
    32→    p.display_name as other_participant_display_name,
    33→    p.type::text as other_participant_type
    34→  FROM conversations c
    35→  INNER JOIN conversation_participants cp ON c.id = cp.conversation_id
    36→  INNER JOIN conversation_participants other_cp ON c.id = other_cp.conversation_id
    37→  INNER JOIN profiles p ON other_cp.profile_id = p.id
    38→  LEFT JOIN LATERAL (
    39→    SELECT content, created_at
    40→    FROM messages
    41→    WHERE conversation_id = c.id
    42→    ORDER BY created_at DESC
    43→    LIMIT 1
    44→  ) last_msg ON true
    45→  LEFT JOIN LATERAL (
    46→    SELECT COUNT(*) as unread_count
    47→    FROM messages
    48→    WHERE conversation_id = c.id
    49→      AND sender_id != user_id
    50→      AND created_at > cp.last_read_at
    51→  ) unread ON true
    52→  WHERE cp.profile_id = user_id
    53→    AND other_cp.profile_id != user_id
    54→  ORDER BY last_msg.created_at DESC NULLS LAST
    55→  LIMIT page_limit
    56→  OFFSET page_offset;
    57→END;
    58→$$ LANGUAGE plpgsql STABLE
    59→SET search_path = public;
    60→
    61→-- Update find_matching_workers to include display_name in profile jsonb
    62→CREATE OR REPLACE FUNCTION find_matching_workers(
    63→  p_job_id uuid,
    64→  p_limit integer DEFAULT 20,
    65→  p_min_score integer DEFAULT 20
    66→)
    67→RETURNS TABLE (
    68→  worker_id uuid,
    69→  score integer,
    70→  summary text,
    71→  reasons text[],
    72→  profile jsonb
    73→) AS $$
    74→DECLARE
    75→  v_job_record RECORD;
    76→  v_job_location point;
    77→BEGIN
    78→  SELECT
    79→    j.id,
    80→    j.trade,
    81→    j.certifications_required,
    82→    j.start_date,
    83→    j.duration,
    84→    j.suburb,
    85→    j.location,
    86→    j.company_id
    87→  INTO v_job_record
    88→  FROM jobs j
    89→  WHERE j.id = p_job_id AND j.status = 'active';
    90→
    91→  IF v_job_record IS NULL THEN
    92→    RETURN;
    93→  END IF;
    94→
    95→  v_job_location := v_job_record.location;
    96→
    97→  RETURN QUERY
    98→  WITH
    99→  existing_matches AS (
   100→    SELECT m.worker_id AS excluded_worker_id
   101→    FROM matches m
   102→    WHERE m.job_id = p_job_id
   103→  ),
   104→  worker_ratings AS (
   105→    SELECT
   106→      r.to_id AS rating_worker_id,
   107→      AVG(r.score)::decimal AS avg_rating,
   108→      COUNT(*)::integer AS rating_count
   109→    FROM ratings r
   110→    GROUP BY r.to_id
   111→  ),
   112→  scored_workers AS (
   113→    SELECT
   114→      p.id AS worker_profile_id,
   115→      p.name AS worker_name,
   116→      p.display_name AS worker_display_name,
   117→      p.suburb AS worker_suburb,
   118→      p.location AS worker_location,
   119→      wp.trades AS worker_trades,
   120→      wp.availability AS worker_availability,
   121→      wp.experience_years,
   122→      wp.certifications AS worker_certs,
   123→      wp.bio,
   124→      wp.white_card_verified,
   125→      CASE
   126→        WHEN v_job_record.trade = ANY(wp.trades) THEN 40
   127→        WHEN 'Labourer' = ANY(wp.trades) THEN 10
   128→        WHEN v_job_record.trade IN ('Steel Fixer', 'Formworker', 'Concreter', 'Scaffolder')
   129→          AND wp.trades && ARRAY['Steel Fixer', 'Formworker', 'Concreter', 'Scaffolder']::text[] THEN 15
   130→        WHEN v_job_record.trade IN ('Painter', 'Tiler', 'Plasterer')
   131→          AND wp.trades && ARRAY['Painter', 'Tiler', 'Plasterer']::text[] THEN 15
   132→        WHEN v_job_record.trade IN ('Electrician', 'Plumber')
   133→          AND wp.trades && ARRAY['Electrician', 'Plumber']::text[] THEN 15
   134→        WHEN v_job_record.trade IN ('Crane Operator', 'Excavator Operator')
   135→          AND wp.trades && ARRAY['Crane Operator', 'Excavator Operator']::text[] THEN 15
   136→        WHEN v_job_record.trade IN ('Bricklayer', 'Roofer', 'Welder')
   137→          AND wp.trades && ARRAY['Bricklayer', 'Roofer', 'Welder']::text[] THEN 15
   138→        ELSE 0
   139→      END AS trade_score,
   140→      CASE wp.availability
   141→        WHEN 'flexible' THEN 20
   142→        WHEN 'full-time' THEN 18
   143→        WHEN 'casual' THEN 12
   144→        WHEN 'weekends' THEN 8
   145→        ELSE 0
   146→      END AS availability_score,
   147→      CASE
   148→        WHEN wp.experience_years >= 10 THEN 15
   149→        WHEN wp.experience_years >= 7 THEN 13
   150→        WHEN wp.experience_years >= 4 THEN 11
   151→        WHEN wp.experience_years >= 2 THEN 8
   152→        WHEN wp.experience_years >= 1 THEN 5
   153→        WHEN wp.experience_years IS NOT NULL THEN 2
   154→        ELSE 0
   155→      END AS experience_score,
   156→      CASE
   157→        WHEN array_length(v_job_record.certifications_required, 1) IS NULL
   158→          OR array_length(v_job_record.certifications_required, 1) = 0 THEN 15
   159→        ELSE
   160→          ROUND(
   161→            (
   162→              COALESCE(
   163→                array_length(
   164→                  ARRAY(
   165→                    SELECT unnest(v_job_record.certifications_required)
   166→                    INTERSECT
   167→                    SELECT unnest(wp.certifications)
   168→                  ),
   169→                  1
   170→                ),
   171→                0
   172→              )::decimal /
   173→              array_length(v_job_record.certifications_required, 1)
   174→            ) * 15
   175→          )
   176→      END AS cert_score,
   177→      CASE
   178→        WHEN v_job_location IS NULL OR p.location IS NULL THEN 0
   179→        WHEN p.location <-> v_job_location < 0.01 THEN 10
   180→        WHEN p.location <-> v_job_location < 0.05 THEN 7
   181→        WHEN p.location <-> v_job_location < 0.1 THEN 5
   182→        WHEN p.suburb = v_job_record.suburb THEN 10
   183→        WHEN p.suburb ILIKE '%' || v_job_record.suburb || '%'
   184→          OR v_job_record.suburb ILIKE '%' || p.suburb || '%' THEN 7
   185→        ELSE 0
   186→      END AS location_score,
   187→      COALESCE(
   188→        ROUND((wr.avg_rating / 5.0) * 10),
   189→        0
   190→      ) AS rating_score,
   191→      wr.avg_rating,
   192→      wr.rating_count
   193→    FROM profiles p
   194→    INNER JOIN worker_profiles wp ON wp.profile_id = p.id
   195→    LEFT JOIN worker_ratings wr ON wr.rating_worker_id = p.id
   196→    WHERE
   197→      p.onboarding_completed = true
   198→      AND p.type = 'worker'
   199→      AND p.id NOT IN (SELECT excluded_worker_id FROM existing_matches)
   200→  ),
   201→  final_scores AS (
   202→    SELECT
   203→      sw.*,
   204→      (sw.trade_score + sw.availability_score + sw.experience_score +
   205→       sw.cert_score + sw.location_score + sw.rating_score) AS total_score,
   206→      ARRAY_REMOVE(ARRAY[
   207→        CASE WHEN sw.trade_score = 40 THEN 'Trades as ' || v_job_record.trade
   208→             WHEN sw.trade_score = 15 THEN 'Related trade experience'
   209→             WHEN sw.trade_score = 10 THEN 'General labourer available'
   210→             ELSE NULL END,
   211→        CASE WHEN sw.availability_score > 0 THEN 'Available ' || sw.worker_availability ELSE NULL END,
   212→        CASE WHEN sw.experience_score > 0 THEN sw.experience_years || '+ years experience' ELSE NULL END,
   213→        CASE WHEN sw.cert_score > 0 AND array_length(v_job_record.certifications_required, 1) > 0
   214→             THEN 'Has required certifications' ELSE NULL END,
   215→        CASE WHEN sw.location_score > 0 THEN 'Based in ' || sw.worker_suburb ELSE NULL END,
   216→        CASE WHEN sw.rating_score >= 8 THEN ROUND(sw.avg_rating::numeric, 1) || ' star rating' ELSE NULL END
   217→      ], NULL) AS match_reasons
   218→    FROM scored_workers sw
   219→  )
   220→  SELECT
   221→    fs.worker_profile_id,
   222→    LEAST(fs.total_score, 100) AS score,
   223→    array_to_string(fs.match_reasons[:3], '. ') || '.' AS summary,
   224→    fs.match_reasons AS reasons,
   225→    jsonb_build_object(
   226→      'id', fs.worker_profile_id,
   227→      'name', fs.worker_name,
   228→      'display_name', fs.worker_display_name,
   229→      'suburb', fs.worker_suburb,
   230→      'trades', fs.worker_trades,
   231→      'experience_years', fs.experience_years,
   232→      'availability', fs.worker_availability,
   233→      'certifications', fs.worker_certs,
   234→      'bio', fs.bio,
   235→      'white_card_verified', fs.white_card_verified,
   236→      'avg_rating', COALESCE(fs.avg_rating, 0),
   237→      'rating_count', COALESCE(fs.rating_count, 0)
   238→    ) AS profile
   239→  FROM final_scores fs
   240→  WHERE fs.total_score >= p_min_score
   241→  ORDER BY fs.total_score DESC, fs.experience_years DESC NULLS LAST
   242→  LIMIT p_limit;
   243→END;
   244→$$ LANGUAGE plpgsql SECURITY DEFINER;
   245→

<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→-- Migration: Create ABN cache table for storing ABR lookup results
     2→-- Created: 2026-02-10
     3→-- Description: Caches ABN lookup results from ABR to reduce API calls and improve performance
     4→--
     5→-- Features:
     6→-- - Stores validated ABN data with company details
     7→-- - Automatic expiry (30 days for valid ABNs, 7 days for invalid/not found)
     8→-- - Unique constraint on ABN
     9→-- - Index for fast lookups
    10→-- - Created/updated timestamps for monitoring
    11→
    12→-- ============================================
    13→-- ABN CACHE TABLE
    14→-- ============================================
    15→
    16→CREATE TABLE abn_cache (
    17→  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
    18→  abn text NOT NULL UNIQUE,
    19→  company_name text NOT NULL,
    20→  status text NOT NULL,
    21→  status_date text,
    22→  state text,
    23→  postcode text,
    24→  entity_type text,
    25→  entity_type_code text,
    26→  gst_registered boolean,
    27→  acn text,
    28→
    29→  -- Cache metadata
    30→  cached_at timestamptz DEFAULT now(),
    31→  updated_at timestamptz DEFAULT now(),
    32→  expires_at timestamptz NOT NULL,
    33→
    34→  -- ABR response data (stored as JSON for flexibility)
    35→  abr_response jsonb,
    36→
    37→  -- Cache statistics
    38→  lookup_count integer DEFAULT 1,
    39→  last_lookup_at timestamptz DEFAULT now()
    40→);
    41→
    42→-- ============================================
    43→-- INDEXES
    44→-- ============================================
    45→
    46→-- Primary lookup index (ABN)
    47→CREATE INDEX idx_abn_cache_abn ON abn_cache (abn);
    48→
    49→-- Expiry index for cleanup jobs
    50→CREATE INDEX idx_abn_cache_expires_at ON abn_cache (expires_at);
    51→
    52→-- Last lookup index for cache hit statistics
    53→CREATE INDEX idx_abn_cache_last_lookup ON abn_cache (last_lookup_at);
    54→
    55→-- ============================================
    56→-- FUNCTIONS
    57→-- ============================================
    58→
    59→-- Function to auto-update updated_at timestamp
    60→CREATE OR REPLACE FUNCTION update_abn_cache_updated_at()
    61→RETURNS trigger AS $$
    62→BEGIN
    63→  NEW.updated_at = now();
    64→  RETURN NEW;
    65→END;
    66→$$ LANGUAGE plpgsql;
    67→
    68→-- Trigger for updated_at
    69→CREATE TRIGGER abn_cache_updated_at
    70→  BEFORE UPDATE ON abn_cache
    71→  FOR EACH ROW EXECUTE FUNCTION update_abn_cache_updated_at();
    72→
    73→-- Function to calculate expiry based on ABN status
    74→CREATE OR REPLACE FUNCTION calculate_abn_expiry(abn_status text, base_date timestamptz DEFAULT now())
    75→RETURNS timestamptz AS $$
    76→BEGIN
    77→  -- Valid ABNs: 30 days expiry
    78→  IF lower(abn_status) = 'active' THEN
    79→    RETURN base_date + INTERVAL '30 days';
    80→
    81→  -- Invalid/not found ABNs: 7 days expiry (shorter to allow retry)
    82→  ELSE
    83→    RETURN base_date + INTERVAL '7 days';
    84→  END IF;
    85→END;
    86→$$ LANGUAGE plpgsql IMMUTABLE;
    87→
    88→-- ============================================
    89→-- RLS POLICIES (Read-only for authenticated users)
    90→-- ============================================
    91→
    92→-- Enable RLS
    93→ALTER TABLE abn_cache ENABLE ROW LEVEL SECURITY;
    94→
    95→-- Allow all authenticated users to read cached ABN data
    96→CREATE POLICY "Allow read access to all authenticated users" ON abn_cache
    97→  FOR SELECT USING (auth.role() = 'authenticated');
    98→
    99→-- Only service role can insert/update/delete (via API or admin operations)
   100→CREATE POLICY "Service role can manage cache" ON abn_cache
   101→  FOR ALL USING (auth.jwt() ->> 'role' = 'service_role');
   102→
   103→-- ============================================
   104→-- HELPER FUNCTIONS FOR API INTEGRATION
   105→-- ============================================
   106→
   107→-- Function to get cached ABN (returns null if expired or not found)
   108→CREATE OR REPLACE FUNCTION get_cached_abn(p_abn text)
   109→RETURNS TABLE (
   110→  id uuid,
   111→  abn text,
   112→  company_name text,
   113→  status text,
   114→  status_date text,
   115→  state text,
   116→  postcode text,
   117→  entity_type text,
   118→  entity_type_code text,
   119→  gst_registered boolean,
   120→  acn text,
   121→  cached_at timestamptz,
   122→  expires_at timestamptz,
   123→  lookup_count integer,
   124→  last_lookup_at timestamptz,
   125→  abr_response jsonb
   126→) AS $$
   127→BEGIN
   128→  RETURN QUERY
   129→  SELECT
   130→    ac.id,
   131→    ac.abn,
   132→    ac.company_name,
   133→    ac.status,
   134→    ac.status_date,
   135→    ac.state,
   136→    ac.postcode,
   137→    ac.entity_type,
   138→    ac.entity_type_code,
   139→    ac.gst_registered,
   140→    ac.acn,
   141→    ac.cached_at,
   142→    ac.expires_at,
   143→    ac.lookup_count,
   144→    ac.last_lookup_at,
   145→    ac.abr_response
   146→  FROM abn_cache ac
   147→  WHERE ac.abn = p_abn
   148→    AND ac.expires_at > now()  -- Not expired
   149→  LIMIT 1;
   150→END;
   151→$$ LANGUAGE plpgsql SECURITY DEFINER;
   152→
   153→-- Function to get cache statistics
   154→CREATE OR REPLACE FUNCTION get_abn_cache_stats()
   155→RETURNS TABLE (
   156→  total_cached bigint,
   157→  active_entries bigint,
   158→  expired_entries bigint,
   159→  avg_lookups numeric,
   160→  max_lookups integer
   161→) AS $$
   162→BEGIN
   163→  RETURN QUERY
   164→  SELECT
   165→    COUNT(*) as total_cached,
   166→    COUNT(*) FILTER (WHERE expires_at > now()) as active_entries,
   167→    COUNT(*) FILTER (WHERE expires_at <= now()) as expired_entries,
   168→    AVG(lookup_count) as avg_lookups,
   169→    MAX(lookup_count) as max_lookups
   170→  FROM abn_cache;
   171→END;
   172→$$ LANGUAGE plpgsql SECURITY DEFINER;
   173→
   174→-- Function to increment lookup count and update last_lookup_at
   175→CREATE OR REPLACE FUNCTION increment_abn_lookup(p_abn text)
   176→RETURNS void AS $$
   177→BEGIN
   178→  UPDATE abn_cache
   179→  SET
   180→    lookup_count = lookup_count + 1,
   181→    last_lookup_at = now()
   182→  WHERE abn = p_abn;
   183→END;
   184→$$ LANGUAGE plpgsql SECURITY DEFINER;
   185→
   186→-- ============================================
   187→-- CLEANUP FUNCTION (can be run periodically)
   188→-- ============================================
   189→
   190→CREATE OR REPLACE FUNCTION cleanup_expired_abn_cache()
   191→RETURNS TABLE (deleted_count integer) AS $$
   192→DECLARE
   193→  deleted integer;
   194→BEGIN
   195→  DELETE FROM abn_cache
   196→  WHERE expires_at <= now();
   197→
   198→  GET DIAGNOSTICS deleted = ROW_COUNT;
   199→  RETURN QUERY SELECT deleted;
   200→END;
   201→$$ LANGUAGE plpgsql SECURITY DEFINER;
   202→
   203→-- ============================================
   204→-- INITIAL DATA (if needed)
   205→-- ============================================
   206→
   207→-- Insert some common test ABNs for development (optional)
   208→-- These will be overwritten by real data when looked up
   209→/*
   210→INSERT INTO abn_cache (
   211→  abn,
   212→  company_name,
   213→  status,
   214→  status_date,
   215→  state,
   216→  postcode,
   217→  entity_type,
   218→  entity_type_code,
   219→  gst_registered,
   220→  acn,
   221→  expires_at
   222→) VALUES
   223→  ('51824753556', 'TEST COMPANY PTY LTD', 'Active', '2020-01-01', 'NSW', '2000', 'Australian Private Company', 'PRV', true, '123456789', now() + INTERVAL '30 days'),
   224→  ('34241177887', 'EXAMPLE BUILDERS PTY LTD', 'Active', '2019-06-15', 'VIC', '3000', 'Australian Private Company', 'PRV', true, '987654321', now() + INTERVAL '30 days')
   225→ON CONFLICT (abn) DO NOTHING;
   226→*/
   227→
   228→-- ============================================
   229→-- VERIFICATION QUERIES
   230→-- ============================================
   231→
   232→-- Check cache statistics
   233→/*
   234→SELECT
   235→  COUNT(*) as total_cached,
   236→  COUNT(*) FILTER (WHERE expires_at > now()) as active_entries,
   237→  COUNT(*) FILTER (WHERE expires_at <= now()) as expired_entries,
   238→  AVG(lookup_count) as avg_lookups,
   239→  MAX(lookup_count) as max_lookups
   240→FROM abn_cache;
   241→*/
   242→
   243→-- Check cache hit rate (run this after some usage)
   244→/*
   245→SELECT
   246→  status,
   247→  COUNT(*) as count,
   248→  AVG(lookup_count) as avg_lookups,
   249→  MIN(cached_at) as first_cached,
   250→  MAX(last_lookup_at) as last_lookup
   251→FROM abn_cache
   252→WHERE expires_at > now()
   253→GROUP BY status
   254→ORDER BY count DESC;
   255→*/

<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→-- Migration: Add DELETE policies to all tables
     2→-- Created: 2026-02-09
     3→-- Description: Adds RLS DELETE policies so users can delete their own data.
     4→--              Service role bypasses RLS by default and can delete anything.
     5→
     6→BEGIN;
     7→
     8→-- ============================================
     9→-- profiles: Users can delete their own profile
    10→-- ============================================
    11→DROP POLICY IF EXISTS "Users can delete their own profile" ON profiles;
    12→CREATE POLICY "Users can delete their own profile"
    13→  ON profiles FOR DELETE
    14→  USING (auth.uid() = id);
    15→
    16→-- ============================================
    17→-- companies: Contractors can delete their own company
    18→-- ============================================
    19→DROP POLICY IF EXISTS "Contractors can delete their own company" ON companies;
    20→CREATE POLICY "Contractors can delete their own company"
    21→  ON companies FOR DELETE
    22→  USING (profile_id = auth.uid());
    23→
    24→-- ============================================
    25→-- worker_profiles: Workers can delete their own profile
    26→-- ============================================
    27→DROP POLICY IF EXISTS "Workers can delete their own profile" ON worker_profiles;
    28→CREATE POLICY "Workers can delete their own profile"
    29→  ON worker_profiles FOR DELETE
    30→  USING (profile_id = auth.uid());
    31→
    32→-- ============================================
    33→-- jobs: Contractors can delete their own jobs
    34→-- ============================================
    35→DROP POLICY IF EXISTS "Contractors can delete their own jobs" ON jobs;
    36→CREATE POLICY "Contractors can delete their own jobs"
    37→  ON jobs FOR DELETE
    38→  USING (company_id IN (
    39→    SELECT id FROM companies WHERE profile_id = auth.uid()
    40→  ));
    41→
    42→-- ============================================
    43→-- matches: Involved parties can delete matches
    44→-- Worker who applied OR contractor who owns the job
    45→-- ============================================
    46→DROP POLICY IF EXISTS "Involved parties can delete matches" ON matches;
    47→DROP POLICY IF EXISTS "Workers can withdraw their own applications" ON matches;
    48→CREATE POLICY "Involved parties can delete matches"
    49→  ON matches FOR DELETE
    50→  USING (
    51→    worker_id = auth.uid()
    52→    OR job_id IN (
    53→      SELECT j.id FROM jobs j
    54→      JOIN companies c ON j.company_id = c.id
    55→      WHERE c.profile_id = auth.uid()
    56→    )
    57→  );
    58→
    59→-- ============================================
    60→-- ratings: Users can delete ratings they authored
    61→-- ============================================
    62→DROP POLICY IF EXISTS "Users can delete their own ratings" ON ratings;
    63→CREATE POLICY "Users can delete their own ratings"
    64→  ON ratings FOR DELETE
    65→  USING (from_id = auth.uid());
    66→
    67→-- ============================================
    68→-- payments: Company owners can delete their payments
    69→-- ============================================
    70→DROP POLICY IF EXISTS "Company owners can delete their payments" ON payments;
    71→CREATE POLICY "Company owners can delete their payments"
    72→  ON payments FOR DELETE
    73→  USING (company_id IN (
    74→    SELECT id FROM companies WHERE profile_id = auth.uid()
    75→  ));
    76→
    77→-- ============================================
    78→-- notifications: Users can delete their own notifications
    79→-- ============================================
    80→DROP POLICY IF EXISTS "Users can delete their own notifications" ON notifications;
    81→CREATE POLICY "Users can delete their own notifications"
    82→  ON notifications FOR DELETE
    83→  USING (profile_id = auth.uid());
    84→
    85→-- ============================================
    86→-- conversations: Participants can delete conversations they belong to
    87→-- ============================================
    88→DROP POLICY IF EXISTS "Users can delete their own conversations" ON conversations;
    89→CREATE POLICY "Users can delete their own conversations"
    90→  ON conversations FOR DELETE
    91→  USING (id IN (
    92→    SELECT conversation_id FROM conversation_participants
    93→    WHERE profile_id = auth.uid()
    94→  ));
    95→
    96→-- ============================================
    97→-- conversation_participants: Users can remove themselves from conversations
    98→-- ============================================
    99→DROP POLICY IF EXISTS "Users can delete their own participation" ON conversation_participants;
   100→CREATE POLICY "Users can delete their own participation"
   101→  ON conversation_participants FOR DELETE
   102→  USING (profile_id = auth.uid());
   103→
   104→-- ============================================
   105→-- messages: Users can delete their own messages
   106→-- ============================================
   107→DROP POLICY IF EXISTS "Users can delete their own messages" ON messages;
   108→CREATE POLICY "Users can delete their own messages"
   109→  ON messages FOR DELETE
   110→  USING (sender_id = auth.uid());
   111→
   112→COMMIT;
   113→

<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→// Simple test to validate the voice-complete endpoint structure
     2→// This file can be used to manually test the endpoint
     3→
     4→export interface VoiceCompleteRequest {
     5→  audio?: File;
     6→  transcript?: string;
     7→}
     8→
     9→export interface VoiceCompleteResponse {
    10→  transcript: string;
    11→  parsed_data: {
    12→    trades: string[];
    13→    trade: string | null;
    14→    experience_years: number | null;
    15→    location: string | null;
    16→    availability: string | null;
    17→    additional_details: string | null;
    18→  };
    19→  generated_profile: {
    20→    bio: string;
    21→    skills: string[];
    22→    certifications: string[];
    23→  };
    24→  error?: string;
    25→  fallback?: boolean;
    26→  timing?: {
    27→    had_audio: boolean;
    28→    processing_complete: boolean;
    29→  };
    30→}
    31→
    32→// Example usage test
    33→export function testVoiceCompleteEndpoint() {
    34→  // Test with transcript only
    35→  const testTranscript = "I'm a chippie with 8 years experience in Parramatta, looking for full-time work";
    36→
    37→  // This would be called from the frontend
    38→  return fetch("/api/ai/voice-complete", {
    39→    method: "POST",
    40→    headers: { "Content-Type": "application/json" },
    41→    body: JSON.stringify({ transcript: testTranscript }),
    42→  });
    43→}

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


Good, I've read all the files. Now I need to understand the structure of the `useNotifications` hook to add the user-specific filter. The hook needs to get the userId first before subscribing. Let me check how the userId is available in the subscription effect.

Looking at the code, the `useNotifications` subscription effect (lines 143-207) doesn't have access to the userId. I need to add a state variable for it and use it in the filter. Similarly for `useUnreadCount` (lines 269-304). Let me now make all the edits.

Let me start with all the edits in parallel.

**Edit 1: Fix `useNotifications` hook** - I need to add userId state and filter the realtime subscription.

**Edit 2: Fix `useUnreadCount` hook** - Same userId-based filter approach.

**Edit 3: Delete the test file.**

**Edit 4: Create the migration file.**

First, let me do the notification hook edits and delete the test file.


Edit

export function useNotifications(options: UseNotificationsOptions = {}): UseNotificationsReturn {
  const { limit = 50, unreadOnly = false } = options;
  const [notifications, setNotifications] = useState<Notification[]>([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<Error | null>(null);
  const channelRef = useRef<RealtimeChannel | null>(null);
  const supabase = createClient();

export function useNotifications(options: UseNotificationsOptions = {}): UseNotificationsReturn {
  const { limit = 50, unreadOnly = false } = options;
  const [notifications, setNotifications] = useState<Notification[]>([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<Error | null>(null);
  const [userId, setUserId] = useState<string | null>(null);
  const channelRef = useRef<RealtimeChannel | null>(null);
  const supabase = createClient();


The file /home/ccuser/the-50-dollar-app/src/hooks/use-notifications.ts has been updated successfully.


      const { data: { user } } = await supabase.auth.getUser();
      if (!user) {
        setNotifications([]);
        setLoading(false);
        return;
      }

      let query = supabase

      const { data: { user } } = await supabase.auth.getUser();
      if (!user) {
        setNotifications([]);
        setUserId(null);
        setLoading(false);
        return;
      }

      setUserId(user.id);

      let query = supabase


Now update the `useNotifications` realtime subscription to filter by userId:


  // Subscribe to real-time updates
  useEffect(() => {
    // Clean up previous channel if it exists
    if (channelRef.current) {
      supabase.removeChannel(channelRef.current);
    }

    const channel = supabase
      .channel(`notifications-${Date.now()}`)
      .on(
        "postgres_changes",
        {
          event: "INSERT",
          schema: "public",
          table: "notifications",
        },
        (payload) => {
          // Add new notification to the list
          const newNotification = payload.new as Notification;
          setNotifications((prev) => {
            // Avoid duplicates
            if (prev.some((n) => n.id === newNotification.id)) {
              return prev;
            }
            return [newNotification, ...prev].slice(0, limit);
          });
        }
      )
      .on(
        "postgres_changes",
        {
          event: "UPDATE",
          schema: "public",
          table: "notifications",
        },
        (payload) => {
          // Update existing notification
          const updatedNotification = payload.new as Notification;
          setNotifications((prev) =>
            prev.map((n) => (n.id === updatedNotification.id ? updatedNotification : n))
          );
        }
      )
      .on(
        "postgres_changes",
        {
          event: "DELETE",
          schema: "public",
          table: "notifications",
        },
        (payload) => {
          // Remove deleted notification
          const deletedId = payload.old.id;
          setNotifications((prev) => prev.filter((n) => n.id !== deletedId));
        }
      )
      .subscribe();

    channelRef.current = channel;

    return () => {
      if (channelRef.current) {
        supabase.removeChannel(channelRef.current);
      }
    };
  }, [supabase, limit]);

  // Subscribe to real-time updates
  useEffect(() => {
    if (!userId) return;

    // Clean up previous channel if it exists
    if (channelRef.current) {
      supabase.removeChannel(channelRef.current);
    }

    const channel = supabase
      .channel(`notifications-${Date.now()}`)
      .on(
        "postgres_changes",
        {
          event: "INSERT",
          schema: "public",
          table: "notifications",
          filter: "profile_id=eq." + userId,
        },
        (payload) => {
          // Add new notification to the list
          const newNotification = payload.new as Notification;
          setNotifications((prev) => {
            // Avoid duplicates
            if (prev.some((n) => n.id === newNotification.id)) {
              return prev;
            }
            return [newNotification, ...prev].slice(0, limit);
          });
        }
      )
      .on(
        "postgres_changes",
        {
          event: "UPDATE",
          schema: "public",
          table: "notifications",
          filter: "profile_id=eq." + userId,
        },
        (payload) => {
          // Update existing notification
          const updatedNotification = payload.new as Notification;
          setNotifications((prev) =>
            prev.map((n) => (n.id === updatedNotification.id ? updatedNotification : n))
          );
        }
      )
      .on(
        "postgres_changes",
        {
          event: "DELETE",
          schema: "public",
          table: "notifications",
          filter: "profile_id=eq." + userId,
        },
        (payload) => {
          // Remove deleted notification
          const deletedId = payload.old.id;
          setNotifications((prev) => prev.filter((n) => n.id !== deletedId));
        }
      )
      .subscribe();

    channelRef.current = channel;

    return () => {
      if (channelRef.current) {
        supabase.removeChannel(channelRef.current);
      }
    };
  }, [supabase, limit, userId]);


Now update the `useUnreadCount` hook - add userId state and filter:


export function useUnreadCount(): { count: number; loading: boolean; error: Error | null } {
  const [count, setCount] = useState(0);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<Error | null>(null);
  const channelRef = useRef<RealtimeChannel | null>(null);
  const supabase = createClient();

export function useUnreadCount(): { count: number; loading: boolean; error: Error | null } {
  const [count, setCount] = useState(0);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<Error | null>(null);
  const [userId, setUserId] = useState<string | null>(null);
  const channelRef = useRef<RealtimeChannel | null>(null);
  const supabase = createClient();


      const { data: { user } } = await supabase.auth.getUser();
      if (!user) {
        setCount(0);
        setLoading(false);
        return;
      }

      const { count: unreadCount, error: countError } = await supabase

      const { data: { user } } = await supabase.auth.getUser();
      if (!user) {
        setCount(0);
        setUserId(null);
        setLoading(false);
        return;
      }

      setUserId(user.id);

      const { count: unreadCount, error: countError } = await supabase


Now update the `useUnreadCount` realtime subscription:


  // Subscribe to real-time updates
  useEffect(() => {
    // Clean up previous channel if it exists
    if (channelRef.current) {
      supabase.removeChannel(channelRef.current);
    }

    const channel = supabase
      .channel(`unread-count-${Date.now()}`)
      .on(
        "postgres_changes",
        {
          event: "INSERT",
          schema: "public",
          table: "notifications",
        },
        () => fetchCount()
      )
      .on(
        "postgres_changes",
        {
          event: "UPDATE",
          schema: "public",
          table: "notifications",
        },
        () => fetchCount()
      )
      .subscribe();

    channelRef.current = channel;

    return () => {
      if (channelRef.current) {
        supabase.removeChannel(channelRef.current);
      }
    };
  }, [supabase, fetchCount]);

  // Subscribe to real-time updates
  useEffect(() => {
    if (!userId) return;

    // Clean up previous channel if it exists
    if (channelRef.current) {
      supabase.removeChannel(channelRef.current);
    }

    const channel = supabase
      .channel(`unread-count-${Date.now()}`)
      .on(
        "postgres_changes",
        {
          event: "INSERT",
          schema: "public",
          table: "notifications",
          filter: "profile_id=eq." + userId,
        },
        () => fetchCount()
      )
      .on(
        "postgres_changes",
        {
          event: "UPDATE",
          schema: "public",
          table: "notifications",
          filter: "profile_id=eq." + userId,
        },
        () => fetchCount()
      )
      .subscribe();

    channelRef.current = channel;

    return () => {
      if (channelRef.current) {
        supabase.removeChannel(channelRef.current);
      }
    };
  }, [supabase, fetchCount, userId]);


Now delete the test file and create the migration file in parallel:


Bash

Delete test file from production


Write

-- Migration: Fix SECURITY DEFINER functions with SET search_path
-- Created: 2026-02-10
-- Description: Adds SET search_path = public to all SECURITY DEFINER functions
--              to prevent search_path hijacking. Also drops the payment DELETE
--              policy and restricts match deletion to non-hired matches only.

BEGIN;

-- ============================================
-- FIX: find_matching_workers - add SET search_path
-- ============================================

CREATE OR REPLACE FUNCTION find_matching_workers(
  p_job_id uuid,
  p_limit integer DEFAULT 20,
  p_min_score integer DEFAULT 20
)
RETURNS TABLE (
  worker_id uuid,
  score integer,
  summary text,
  reasons text[],
  profile jsonb
) AS $$
DECLARE
  v_job_record RECORD;
  v_job_location point;
BEGIN
  SELECT
    j.id,
    j.trade,
    j.certifications_required,
    j.start_date,
    j.duration,
    j.suburb,
    j.location,
    j.company_id
  INTO v_job_record
  FROM jobs j
  WHERE j.id = p_job_id AND j.status = 'active';

  IF v_job_record IS NULL THEN
    RETURN;
  END IF;

  v_job_location := v_job_record.location;

  RETURN QUERY
  WITH
  existing_matches AS (
    SELECT m.worker_id AS excluded_worker_id
    FROM matches m
    WHERE m.job_id = p_job_id
  ),
  worker_ratings AS (
    SELECT
      r.to_id AS rating_worker_id,
      AVG(r.score)::decimal AS avg_rating,
      COUNT(*)::integer AS rating_count
    FROM ratings r
    GROUP BY r.to_id
  ),
  scored_workers AS (
    SELECT
      p.id AS worker_profile_id,
      p.name AS worker_name,
      p.display_name AS worker_display_name,
      p.suburb AS worker_suburb,
      p.location AS worker_location,
      wp.trades AS worker_trades,
      wp.availability AS worker_availability,
      wp.experience_years,
      wp.certifications AS worker_certs,
      wp.bio,
      wp.white_card_verified,
      CASE
        WHEN v_job_record.trade = ANY(wp.trades) THEN 40
        WHEN 'Labourer' = ANY(wp.trades) THEN 10
        WHEN v_job_record.trade IN ('Steel Fixer', 'Formworker', 'Concreter', 'Scaffolder')
          AND wp.trades && ARRAY['Steel Fixer', 'Formworker', 'Concreter', 'Scaffolder']::text[] THEN 15
        WHEN v_job_record.trade IN ('Painter', 'Tiler', 'Plasterer')
          AND wp.trades && ARRAY['Painter', 'Tiler', 'Plasterer']::text[] THEN 15
        WHEN v_job_record.trade IN ('Electrician', 'Plumber')
          AND wp.trades && ARRAY['Electrician', 'Plumber']::text[] THEN 15
        WHEN v_job_record.trade IN ('Crane Operator', 'Excavator Operator')
          AND wp.trades && ARRAY['Crane Operator', 'Excavator Operator']::text[] THEN 15
        WHEN v_job_record.trade IN ('Bricklayer', 'Roofer', 'Welder')
          AND wp.trades && ARRAY['Bricklayer', 'Roofer', 'Welder']::text[] THEN 15
        ELSE 0
      END AS trade_score,
      CASE wp.availability
        WHEN 'flexible' THEN 20
        WHEN 'full-time' THEN 18
        WHEN 'casual' THEN 12
        WHEN 'weekends' THEN 8
        ELSE 0
      END AS availability_score,
      CASE
        WHEN wp.experience_years >= 10 THEN 15
        WHEN wp.experience_years >= 7 THEN 13
        WHEN wp.experience_years >= 4 THEN 11
        WHEN wp.experience_years >= 2 THEN 8
        WHEN wp.experience_years >= 1 THEN 5
        WHEN wp.experience_years IS NOT NULL THEN 2
        ELSE 0
      END AS experience_score,
      CASE
        WHEN array_length(v_job_record.certifications_required, 1) IS NULL
          OR array_length(v_job_record.certifications_required, 1) = 0 THEN 15
        ELSE
          ROUND(
            (
              COALESCE(
                array_length(
                  ARRAY(
                    SELECT unnest(v_job_record.certifications_required)
                    INTERSECT
                    SELECT unnest(wp.certifications)
                  ),
                  1
                ),
                0
              )::decimal /
              array_length(v_job_record.certifications_required, 1)
            ) * 15
          )
      END AS cert_score,
      CASE
        WHEN v_job_location IS NULL OR p.location IS NULL THEN 0
        WHEN p.location <-> v_job_location < 0.01 THEN 10
        WHEN p.location <-> v_job_location < 0.05 THEN 7
        WHEN p.location <-> v_job_location < 0.1 THEN 5
        WHEN p.suburb = v_job_record.suburb THEN 10
        WHEN p.suburb ILIKE '%' || v_job_record.suburb || '%'
          OR v_job_record.suburb ILIKE '%' || p.suburb || '%' THEN 7
        ELSE 0
      END AS location_score,
      COALESCE(
        ROUND((wr.avg_rating / 5.0) * 10),
        0
      ) AS rating_score,
      wr.avg_rating,
      wr.rating_count
    FROM profiles p
    INNER JOIN worker_profiles wp ON wp.profile_id = p.id
    LEFT JOIN worker_ratings wr ON wr.rating_worker_id = p.id
    WHERE
      p.onboarding_completed = true
      AND p.type = 'worker'
      AND p.id NOT IN (SELECT excluded_worker_id FROM existing_matches)
  ),
  final_scores AS (
    SELECT
      sw.*,
      (sw.trade_score + sw.availability_score + sw.experience_score +
       sw.cert_score + sw.location_score + sw.rating_score) AS total_score,
      ARRAY_REMOVE(ARRAY[
        CASE WHEN sw.trade_score = 40 THEN 'Trades as ' || v_job_record.trade
             WHEN sw.trade_score = 15 THEN 'Related trade experience'
             WHEN sw.trade_score = 10 THEN 'General labourer available'
             ELSE NULL END,
        CASE WHEN sw.availability_score > 0 THEN 'Available ' || sw.worker_availability ELSE NULL END,
        CASE WHEN sw.experience_score > 0 THEN sw.experience_years || '+ years experience' ELSE NULL END,
        CASE WHEN sw.cert_score > 0 AND array_length(v_job_record.certifications_required, 1) > 0
             THEN 'Has required certifications' ELSE NULL END,
        CASE WHEN sw.location_score > 0 THEN 'Based in ' || sw.worker_suburb ELSE NULL END,
        CASE WHEN sw.rating_score >= 8 THEN ROUND(sw.avg_rating::numeric, 1) || ' star rating' ELSE NULL END
      ], NULL) AS match_reasons
    FROM scored_workers sw
  )
  SELECT
    fs.worker_profile_id,
    LEAST(fs.total_score, 100) AS score,
    array_to_string(fs.match_reasons[:3], '. ') || '.' AS summary,
    fs.match_reasons AS reasons,
    jsonb_build_object(
      'id', fs.worker_profile_id,
      'name', fs.worker_name,
      'display_name', fs.worker_display_name,
      'suburb', fs.worker_suburb,
      'trades', fs.worker_trades,
      'experience_years', fs.experience_years,
      'availability', fs.worker_availability,
      'certifications', fs.worker_certs,
      'bio', fs.bio,
      'white_card_verified', fs.white_card_verified,
      'avg_rating', COALESCE(fs.avg_rating, 0),
      'rating_count', COALESCE(fs.rating_count, 0)
    ) AS profile
  FROM final_scores fs
  WHERE fs.total_score >= p_min_score
  ORDER BY fs.total_score DESC, fs.experience_years DESC NULLS LAST
  LIMIT p_limit;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER
SET search_path = public;

-- ============================================
-- FIX: get_cached_abn - add SET search_path
-- ============================================

CREATE OR REPLACE FUNCTION get_cached_abn(p_abn text)
RETURNS TABLE (
  id uuid,
  abn text,
  company_name text,
  status text,
  status_date text,
  state text,
  postcode text,
  entity_type text,
  entity_type_code text,
  gst_registered boolean,
  acn text,
  cached_at timestamptz,
  expires_at timestamptz,
  lookup_count integer,
  last_lookup_at timestamptz,
  abr_response jsonb
) AS $$
BEGIN
  RETURN QUERY
  SELECT
    ac.id,
    ac.abn,
    ac.company_name,
    ac.status,
    ac.status_date,
    ac.state,
    ac.postcode,
    ac.entity_type,
    ac.entity_type_code,
    ac.gst_registered,
    ac.acn,
    ac.cached_at,
    ac.expires_at,
    ac.lookup_count,
    ac.last_lookup_at,
    ac.abr_response
  FROM abn_cache ac
  WHERE ac.abn = p_abn
    AND ac.expires_at > now()
  LIMIT 1;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER
SET search_path = public;

-- ============================================
-- FIX: get_abn_cache_stats - add SET search_path
-- ============================================

CREATE OR REPLACE FUNCTION get_abn_cache_stats()
RETURNS TABLE (
  total_cached bigint,
  active_entries bigint,
  expired_entries bigint,
  avg_lookups numeric,
  max_lookups integer
) AS $$
BEGIN
  RETURN QUERY
  SELECT
    COUNT(*) as total_cached,
    COUNT(*) FILTER (WHERE expires_at > now()) as active_entries,
    COUNT(*) FILTER (WHERE expires_at <= now()) as expired_entries,
    AVG(lookup_count) as avg_lookups,
    MAX(lookup_count) as max_lookups
  FROM abn_cache;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER
SET search_path = public;

-- ============================================
-- FIX: increment_abn_lookup - add SET search_path
-- ============================================

CREATE OR REPLACE FUNCTION increment_abn_lookup(p_abn text)
RETURNS void AS $$
BEGIN
  UPDATE abn_cache
  SET
    lookup_count = lookup_count + 1,
    last_lookup_at = now()
  WHERE abn = p_abn;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER
SET search_path = public;

-- ============================================
-- FIX: cleanup_expired_abn_cache - add SET search_path
-- ============================================

CREATE OR REPLACE FUNCTION cleanup_expired_abn_cache()
RETURNS TABLE (deleted_count integer) AS $$
DECLARE
  deleted integer;
BEGIN
  DELETE FROM abn_cache
  WHERE expires_at <= now();

  GET DIAGNOSTICS deleted = ROW_COUNT;
  RETURN QUERY SELECT deleted;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER
SET search_path = public;

-- ============================================
-- FIX: Drop payment DELETE policy (too permissive)
-- ============================================

DROP POLICY IF EXISTS "Company owners can delete their payments" ON payments;

-- ============================================
-- FIX: Restrict match deletion to non-hired matches only
-- ============================================

DROP POLICY IF EXISTS "Involved parties can delete matches" ON matches;
CREATE POLICY "Involved parties can delete matches"
  ON matches FOR DELETE
  USING (
    (
      worker_id = auth.uid()
      OR job_id IN (
        SELECT j.id FROM jobs j
        JOIN companies c ON j.company_id = c.id
        WHERE c.profile_id = auth.uid()
      )
    )
    AND status NOT IN ('hired')
  );

COMMIT;


File created successfully at: /home/ccuser/the-50-dollar-app/supabase/migrations/20260210100000_fix_security_definer_search_path.sql


Now let me verify all edits were applied correctly by reading the modified files.


     1→"use client";
     2→
     3→import { useState, useEffect, useCallback, useRef } from "react";
     4→import { createClient } from "@/lib/supabase/client";
     5→import type { Notification } from "@/lib/types";
     6→import type { RealtimeChannel } from "@supabase/supabase-js";
     7→
     8→interface UseNotificationsOptions {
     9→  limit?: number;
    10→  unreadOnly?: boolean;
    11→}
    12→
    13→interface UseNotificationsReturn {
    14→  notifications: Notification[];
    15→  unreadCount: number;
    16→  loading: boolean;
    17→  error: Error | null;
    18→  markAsRead: (id: string) => Promise<void>;
    19→  markAllAsRead: () => Promise<void>;
    20→  refetch: () => Promise<void>;
    21→}
    22→
    23→/**
    24→ * Hook to fetch and manage notifications from Supabase
    25→ *
    26→ * Features:
    27→ * - Fetches notifications for the current user
    28→ * - Real-time updates via Supabase subscriptions
    29→ * - Mark individual or all notifications as read
    30→ * - Unread count tracking
    31→ *
    32→ * @example
    33→ * ```tsx
    34→ * const { notifications, unreadCount, markAsRead } = useNotifications({ limit: 10 });
    35→ * ```
    36→ */
    37→export function useNotifications(options: UseNotificationsOptions = {}): UseNotificationsReturn {
    38→  const { limit = 50, unreadOnly = false } = options;
    39→  const [notifications, setNotifications] = useState<Notification[]>([]);
    40→  const [loading, setLoading] = useState(true);
    41→  const [error, setError] = useState<Error | null>(null);
    42→  const [userId, setUserId] = useState<string | null>(null);
    43→  const channelRef = useRef<RealtimeChannel | null>(null);
    44→  const supabase = createClient();
    45→
    46→  const fetchNotifications = useCallback(async () => {
    47→    try {
    48→      setLoading(true);
    49→      setError(null);
    50→
    51→      const { data: { user } } = await supabase.auth.getUser();
    52→      if (!user) {
    53→        setNotifications([]);
    54→        setUserId(null);
    55→        setLoading(false);
    56→        return;
    57→      }
    58→
    59→      setUserId(user.id);
    60→
    61→      let query = supabase
    62→        .from("notifications")
    63→        .select("id, profile_id, type, title, body, data, read, created_at")
    64→        .eq("profile_id", user.id)
    65→        .order("created_at", { ascending: false });
    66→
    67→      if (unreadOnly) {
    68→        query = query.eq("read", false);
    69→      }
    70→
    71→      if (limit) {
    72→        query = query.limit(limit);
    73→      }
    74→
    75→      const { data, error: fetchError } = await query;
    76→
    77→      if (fetchError) {
    78→        throw new Error(fetchError.message);
    79→      }
    80→
    81→      setNotifications((data as Notification[]) ?? []);
    82→    } catch (err) {
    83→      setError(err instanceof Error ? err : new Error("Failed to fetch notifications"));
    84→    } finally {
    85→      setLoading(false);
    86→    }
    87→  }, [supabase, limit, unreadOnly]);
    88→
    89→  // Calculate unread count from notifications
    90→  const unreadCount = notifications.filter((n) => !n.read).length;
    91→
    92→  // Mark a single notification as read
    93→  const markAsRead = useCallback(async (id: string) => {
    94→    try {
    95→      const { error: updateError } = await supabase
    96→        .from("notifications")
    97→        .update({ read: true })
    98→        .eq("id", id);
    99→
   100→      if (updateError) {
   101→        throw new Error(updateError.message);
   102→      }
   103→
   104→      // Optimistically update local state
   105→      setNotifications((prev) =>
   106→        prev.map((n) => (n.id === id ? { ...n, read: true } : n))
   107→      );
   108→    } catch (err) {
   109→      throw err;
   110→    }
   111→  }, [supabase]);
   112→
   113→  // Mark all notifications as read
   114→  const markAllAsRead = useCallback(async () => {
   115→    try {
   116→      const { data: { user } } = await supabase.auth.getUser();
   117→      if (!user) return;
   118→
   119→      const { error: updateError } = await supabase
   120→        .from("notifications")
   121→        .update({ read: true })
   122→        .eq("profile_id", user.id)
   123→        .eq("read", false);
   124→
   125→      if (updateError) {
   126→        throw new Error(updateError.message);
   127→      }
   128→
   129→      // Optimistically update local state
   130→      setNotifications((prev) => prev.map((n) => ({ ...n, read: true })));
   131→    } catch (err) {
   132→      throw err;
   133→    }
   134→  }, [supabase]);
   135→
   136→  // Refetch notifications
   137→  const refetch = useCallback(async () => {
   138→    await fetchNotifications();
   139→  }, [fetchNotifications]);
   140→
   141→  // Initial fetch
   142→  useEffect(() => {
   143→    fetchNotifications();
   144→  }, [fetchNotifications]);
   145→
   146→  // Subscribe to real-time updates
   147→  useEffect(() => {
   148→    if (!userId) return;
   149→
   150→    // Clean up previous channel if it exists
   151→    if (channelRef.current) {
   152→      supabase.removeChannel(channelRef.current);
   153→    }
   154→
   155→    const channel = supabase
   156→      .channel(`notifications-${Date.now()}`)
   157→      .on(
   158→        "postgres_changes",
   159→        {
   160→          event: "INSERT",
   161→          schema: "public",
   162→          table: "notifications",
   163→          filter: "profile_id=eq." + userId,
   164→        },
   165→        (payload) => {
   166→          // Add new notification to the list
   167→          const newNotification = payload.new as Notification;
   168→          setNotifications((prev) => {
   169→            // Avoid duplicates
   170→            if (prev.some((n) => n.id === newNotification.id)) {
   171→              return prev;
   172→            }
   173→            return [newNotification, ...prev].slice(0, limit);
   174→          });
   175→        }
   176→      )
   177→      .on(
   178→        "postgres_changes",
   179→        {
   180→          event: "UPDATE",
   181→          schema: "public",
   182→          table: "notifications",
   183→          filter: "profile_id=eq." + userId,
   184→        },
   185→        (payload) => {
   186→          // Update existing notification
   187→          const updatedNotification = payload.new as Notification;
   188→          setNotifications((prev) =>
   189→            prev.map((n) => (n.id === updatedNotification.id ? updatedNotification : n))
   190→          );
   191→        }
   192→      )
   193→      .on(
   194→        "postgres_changes",
   195→        {
   196→          event: "DELETE",
   197→          schema: "public",
   198→          table: "notifications",
   199→          filter: "profile_id=eq." + userId,
   200→        },
   201→        (payload) => {
   202→          // Remove deleted notification
   203→          const deletedId = payload.old.id;
   204→          setNotifications((prev) => prev.filter((n) => n.id !== deletedId));
   205→        }
   206→      )
   207→      .subscribe();
   208→
   209→    channelRef.current = channel;
   210→
   211→    return () => {
   212→      if (channelRef.current) {
   213→        supabase.removeChannel(channelRef.current);
   214→      }
   215→    };
   216→  }, [supabase, limit, userId]);
   217→
   218→  return {
   219→    notifications,
   220→    unreadCount,
   221→    loading,
   222→    error,
   223→    markAsRead,
   224→    markAllAsRead,
   225→    refetch,
   226→  };
   227→}
   228→
   229→/**
   230→ * Hook to get only the unread notification count
   231→ * Lightweight version for header/badge use
   232→ *
   233→ * @example
   234→ * ```tsx
   235→ * const { count } = useUnreadCount();
   236→ * ```
   237→ */
   238→export function useUnreadCount(): { count: number; loading: boolean; error: Error | null } {
   239→  const [count, setCount] = useState(0);
   240→  const [loading, setLoading] = useState(true);
   241→  const [error, setError] = useState<Error | null>(null);
   242→  const [userId, setUserId] = useState<string | null>(null);
   243→  const channelRef = useRef<RealtimeChannel | null>(null);
   244→  const supabase = createClient();
   245→
   246→  const fetchCount = useCallback(async () => {
   247→    try {
   248→      const { data: { user } } = await supabase.auth.getUser();
   249→      if (!user) {
   250→        setCount(0);
   251→        setUserId(null);
   252→        setLoading(false);
   253→        return;
   254→      }
   255→
   256→      setUserId(user.id);
   257→
   258→      const { count: unreadCount, error: countError } = await supabase
   259→        .from("notifications")
   260→        .select("*", { count: "exact", head: true })
   261→        .eq("profile_id", user.id)
   262→        .eq("read", false);
   263→
   264→      if (countError) {
   265→        throw new Error(countError.message);
   266→      }
   267→
   268→      setCount(unreadCount ?? 0);
   269→      setError(null);
   270→    } catch (err) {
   271→      setError(err instanceof Error ? err : new Error("Failed to fetch unread count"));
   272→    } finally {
   273→      setLoading(false);
   274→    }
   275→  }, [supabase]);
   276→
   277→  useEffect(() => {
   278→    fetchCount();
   279→  }, [fetchCount]);
   280→
   281→  // Subscribe to real-time updates
   282→  useEffect(() => {
   283→    if (!userId) return;
   284→
   285→    // Clean up previous channel if it exists
   286→    if (channelRef.current) {
   287→      supabase.removeChannel(channelRef.current);
   288→    }
   289→
   290→    const channel = supabase
   291→      .channel(`unread-count-${Date.now()}`)
   292→      .on(
   293→        "postgres_changes",
   294→        {
   295→          event: "INSERT",
   296→          schema: "public",
   297→          table: "notifications",
   298→          filter: "profile_id=eq." + userId,
   299→        },
   300→        () => fetchCount()
   301→      )
   302→      .on(
   303→        "postgres_changes",
   304→        {
   305→          event: "UPDATE",
   306→          schema: "public",
   307→          table: "notifications",
   308→          filter: "profile_id=eq." + userId,
   309→        },
   310→        () => fetchCount()
   311→      )
   312→      .subscribe();
   313→
   314→    channelRef.current = channel;
   315→
   316→    return () => {
   317→      if (channelRef.current) {
   318→        supabase.removeChannel(channelRef.current);
   319→      }
   320→    };
   321→  }, [supabase, fetchCount, userId]);
   322→
   323→  return { count, loading, error };
   324→}
   325→

<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→-- Migration: Fix SECURITY DEFINER functions with SET search_path
     2→-- Created: 2026-02-10
     3→-- Description: Adds SET search_path = public to all SECURITY DEFINER functions
     4→--              to prevent search_path hijacking. Also drops the payment DELETE
     5→--              policy and restricts match deletion to non-hired matches only.
     6→
     7→BEGIN;
     8→
     9→-- ============================================
    10→-- FIX: find_matching_workers - add SET search_path
    11→-- ============================================
    12→
    13→CREATE OR REPLACE FUNCTION find_matching_workers(
    14→  p_job_id uuid,
    15→  p_limit integer DEFAULT 20,
    16→  p_min_score integer DEFAULT 20
    17→)
    18→RETURNS TABLE (
    19→  worker_id uuid,
    20→  score integer,
    21→  summary text,
    22→  reasons text[],
    23→  profile jsonb
    24→) AS $$
    25→DECLARE
    26→  v_job_record RECORD;
    27→  v_job_location point;
    28→BEGIN
    29→  SELECT
    30→    j.id,
    31→    j.trade,
    32→    j.certifications_required,
    33→    j.start_date,
    34→    j.duration,
    35→    j.suburb,
    36→    j.location,
    37→    j.company_id
    38→  INTO v_job_record
    39→  FROM jobs j
    40→  WHERE j.id = p_job_id AND j.status = 'active';
    41→
    42→  IF v_job_record IS NULL THEN
    43→    RETURN;
    44→  END IF;
    45→
    46→  v_job_location := v_job_record.location;
    47→
    48→  RETURN QUERY
    49→  WITH
    50→  existing_matches AS (
    51→    SELECT m.worker_id AS excluded_worker_id
    52→    FROM matches m
    53→    WHERE m.job_id = p_job_id
    54→  ),
    55→  worker_ratings AS (
    56→    SELECT
    57→      r.to_id AS rating_worker_id,
    58→      AVG(r.score)::decimal AS avg_rating,
    59→      COUNT(*)::integer AS rating_count
    60→    FROM ratings r
    61→    GROUP BY r.to_id
    62→  ),
    63→  scored_workers AS (
    64→    SELECT
    65→      p.id AS worker_profile_id,
    66→      p.name AS worker_name,
    67→      p.display_name AS worker_display_name,
    68→      p.suburb AS worker_suburb,
    69→      p.location AS worker_location,
    70→      wp.trades AS worker_trades,
    71→      wp.availability AS worker_availability,
    72→      wp.experience_years,
    73→      wp.certifications AS worker_certs,
    74→      wp.bio,
    75→      wp.white_card_verified,
    76→      CASE
    77→        WHEN v_job_record.trade = ANY(wp.trades) THEN 40
    78→        WHEN 'Labourer' = ANY(wp.trades) THEN 10
    79→        WHEN v_job_record.trade IN ('Steel Fixer', 'Formworker', 'Concreter', 'Scaffolder')
    80→          AND wp.trades && ARRAY['Steel Fixer', 'Formworker', 'Concreter', 'Scaffolder']::text[] THEN 15
    81→        WHEN v_job_record.trade IN ('Painter', 'Tiler', 'Plasterer')
    82→          AND wp.trades && ARRAY['Painter', 'Tiler', 'Plasterer']::text[] THEN 15
    83→        WHEN v_job_record.trade IN ('Electrician', 'Plumber')
    84→          AND wp.trades && ARRAY['Electrician', 'Plumber']::text[] THEN 15
    85→        WHEN v_job_record.trade IN ('Crane Operator', 'Excavator Operator')
    86→          AND wp.trades && ARRAY['Crane Operator', 'Excavator Operator']::text[] THEN 15
    87→        WHEN v_job_record.trade IN ('Bricklayer', 'Roofer', 'Welder')
    88→          AND wp.trades && ARRAY['Bricklayer', 'Roofer', 'Welder']::text[] THEN 15
    89→        ELSE 0
    90→      END AS trade_score,
    91→      CASE wp.availability
    92→        WHEN 'flexible' THEN 20
    93→        WHEN 'full-time' THEN 18
    94→        WHEN 'casual' THEN 12
    95→        WHEN 'weekends' THEN 8
    96→        ELSE 0
    97→      END AS availability_score,
    98→      CASE
    99→        WHEN wp.experience_years >= 10 THEN 15
   100→        WHEN wp.experience_years >= 7 THEN 13
   101→        WHEN wp.experience_years >= 4 THEN 11
   102→        WHEN wp.experience_years >= 2 THEN 8
   103→        WHEN wp.experience_years >= 1 THEN 5
   104→        WHEN wp.experience_years IS NOT NULL THEN 2
   105→        ELSE 0
   106→      END AS experience_score,
   107→      CASE
   108→        WHEN array_length(v_job_record.certifications_required, 1) IS NULL
   109→          OR array_length(v_job_record.certifications_required, 1) = 0 THEN 15
   110→        ELSE
   111→          ROUND(
   112→            (
   113→              COALESCE(
   114→                array_length(
   115→                  ARRAY(
   116→                    SELECT unnest(v_job_record.certifications_required)
   117→                    INTERSECT
   118→                    SELECT unnest(wp.certifications)
   119→                  ),
   120→                  1
   121→                ),
   122→                0
   123→              )::decimal /
   124→              array_length(v_job_record.certifications_required, 1)
   125→            ) * 15
   126→          )
   127→      END AS cert_score,
   128→      CASE
   129→        WHEN v_job_location IS NULL OR p.location IS NULL THEN 0
   130→        WHEN p.location <-> v_job_location < 0.01 THEN 10
   131→        WHEN p.location <-> v_job_location < 0.05 THEN 7
   132→        WHEN p.location <-> v_job_location < 0.1 THEN 5
   133→        WHEN p.suburb = v_job_record.suburb THEN 10
   134→        WHEN p.suburb ILIKE '%' || v_job_record.suburb || '%'
   135→          OR v_job_record.suburb ILIKE '%' || p.suburb || '%' THEN 7
   136→        ELSE 0
   137→      END AS location_score,
   138→      COALESCE(
   139→        ROUND((wr.avg_rating / 5.0) * 10),
   140→        0
   141→      ) AS rating_score,
   142→      wr.avg_rating,
   143→      wr.rating_count
   144→    FROM profiles p
   145→    INNER JOIN worker_profiles wp ON wp.profile_id = p.id
   146→    LEFT JOIN worker_ratings wr ON wr.rating_worker_id = p.id
   147→    WHERE
   148→      p.onboarding_completed = true
   149→      AND p.type = 'worker'
   150→      AND p.id NOT IN (SELECT excluded_worker_id FROM existing_matches)
   151→  ),
   152→  final_scores AS (
   153→    SELECT
   154→      sw.*,
   155→      (sw.trade_score + sw.availability_score + sw.experience_score +
   156→       sw.cert_score + sw.location_score + sw.rating_score) AS total_score,
   157→      ARRAY_REMOVE(ARRAY[
   158→        CASE WHEN sw.trade_score = 40 THEN 'Trades as ' || v_job_record.trade
   159→             WHEN sw.trade_score = 15 THEN 'Related trade experience'
   160→             WHEN sw.trade_score = 10 THEN 'General labourer available'
   161→             ELSE NULL END,
   162→        CASE WHEN sw.availability_score > 0 THEN 'Available ' || sw.worker_availability ELSE NULL END,
   163→        CASE WHEN sw.experience_score > 0 THEN sw.experience_years || '+ years experience' ELSE NULL END,
   164→        CASE WHEN sw.cert_score > 0 AND array_length(v_job_record.certifications_required, 1) > 0
   165→             THEN 'Has required certifications' ELSE NULL END,
   166→        CASE WHEN sw.location_score > 0 THEN 'Based in ' || sw.worker_suburb ELSE NULL END,
   167→        CASE WHEN sw.rating_score >= 8 THEN ROUND(sw.avg_rating::numeric, 1) || ' star rating' ELSE NULL END
   168→      ], NULL) AS match_reasons
   169→    FROM scored_workers sw
   170→  )
   171→  SELECT
   172→    fs.worker_profile_id,
   173→    LEAST(fs.total_score, 100) AS score,
   174→    array_to_string(fs.match_reasons[:3], '. ') || '.' AS summary,
   175→    fs.match_reasons AS reasons,
   176→    jsonb_build_object(
   177→      'id', fs.worker_profile_id,
   178→      'name', fs.worker_name,
   179→      'display_name', fs.worker_display_name,
   180→      'suburb', fs.worker_suburb,
   181→      'trades', fs.worker_trades,
   182→      'experience_years', fs.experience_years,
   183→      'availability', fs.worker_availability,
   184→      'certifications', fs.worker_certs,
   185→      'bio', fs.bio,
   186→      'white_card_verified', fs.white_card_verified,
   187→      'avg_rating', COALESCE(fs.avg_rating, 0),
   188→      'rating_count', COALESCE(fs.rating_count, 0)
   189→    ) AS profile
   190→  FROM final_scores fs
   191→  WHERE fs.total_score >= p_min_score
   192→  ORDER BY fs.total_score DESC, fs.experience_years DESC NULLS LAST
   193→  LIMIT p_limit;
   194→END;
   195→$$ LANGUAGE plpgsql SECURITY DEFINER
   196→SET search_path = public;
   197→
   198→-- ============================================
   199→-- FIX: get_cached_abn - add SET search_path
   200→-- ============================================
   201→
   202→CREATE OR REPLACE FUNCTION get_cached_abn(p_abn text)
   203→RETURNS TABLE (
   204→  id uuid,
   205→  abn text,
   206→  company_name text,
   207→  status text,
   208→  status_date text,
   209→  state text,
   210→  postcode text,
   211→  entity_type text,
   212→  entity_type_code text,
   213→  gst_registered boolean,
   214→  acn text,
   215→  cached_at timestamptz,
   216→  expires_at timestamptz,
   217→  lookup_count integer,
   218→  last_lookup_at timestamptz,
   219→  abr_response jsonb
   220→) AS $$
   221→BEGIN
   222→  RETURN QUERY
   223→  SELECT
   224→    ac.id,
   225→    ac.abn,
   226→    ac.company_name,
   227→    ac.status,
   228→    ac.status_date,
   229→    ac.state,
   230→    ac.postcode,
   231→    ac.entity_type,
   232→    ac.entity_type_code,
   233→    ac.gst_registered,
   234→    ac.acn,
   235→    ac.cached_at,
   236→    ac.expires_at,
   237→    ac.lookup_count,
   238→    ac.last_lookup_at,
   239→    ac.abr_response
   240→  FROM abn_cache ac
   241→  WHERE ac.abn = p_abn
   242→    AND ac.expires_at > now()
   243→  LIMIT 1;
   244→END;
   245→$$ LANGUAGE plpgsql SECURITY DEFINER
   246→SET search_path = public;
   247→
   248→-- ============================================
   249→-- FIX: get_abn_cache_stats - add SET search_path
   250→-- ============================================
   251→
   252→CREATE OR REPLACE FUNCTION get_abn_cache_stats()
   253→RETURNS TABLE (
   254→  total_cached bigint,
   255→  active_entries bigint,
   256→  expired_entries bigint,
   257→  avg_lookups numeric,
   258→  max_lookups integer
   259→) AS $$
   260→BEGIN
   261→  RETURN QUERY
   262→  SELECT
   263→    COUNT(*) as total_cached,
   264→    COUNT(*) FILTER (WHERE expires_at > now()) as active_entries,
   265→    COUNT(*) FILTER (WHERE expires_at <= now()) as expired_entries,
   266→    AVG(lookup_count) as avg_lookups,
   267→    MAX(lookup_count) as max_lookups
   268→  FROM abn_cache;
   269→END;
   270→$$ LANGUAGE plpgsql SECURITY DEFINER
   271→SET search_path = public;
   272→
   273→-- ============================================
   274→-- FIX: increment_abn_lookup - add SET search_path
   275→-- ============================================
   276→
   277→CREATE OR REPLACE FUNCTION increment_abn_lookup(p_abn text)
   278→RETURNS void AS $$
   279→BEGIN
   280→  UPDATE abn_cache
   281→  SET
   282→    lookup_count = lookup_count + 1,
   283→    last_lookup_at = now()
   284→  WHERE abn = p_abn;
   285→END;
   286→$$ LANGUAGE plpgsql SECURITY DEFINER
   287→SET search_path = public;
   288→
   289→-- ============================================
   290→-- FIX: cleanup_expired_abn_cache - add SET search_path
   291→-- ============================================
   292→
   293→CREATE OR REPLACE FUNCTION cleanup_expired_abn_cache()
   294→RETURNS TABLE (deleted_count integer) AS $$
   295→DECLARE
   296→  deleted integer;
   297→BEGIN
   298→  DELETE FROM abn_cache
   299→  WHERE expires_at <= now();
   300→
   301→  GET DIAGNOSTICS deleted = ROW_COUNT;
   302→  RETURN QUERY SELECT deleted;
   303→END;
   304→$$ LANGUAGE plpgsql SECURITY DEFINER
   305→SET search_path = public;
   306→
   307→-- ============================================
   308→-- FIX: Drop payment DELETE policy (too permissive)
   309→-- ============================================
   310→
   311→DROP POLICY IF EXISTS "Company owners can delete their payments" ON payments;
   312→
   313→-- ============================================
   314→-- FIX: Restrict match deletion to non-hired matches only
   315→-- ============================================
   316→
   317→DROP POLICY IF EXISTS "Involved parties can delete matches" ON matches;
   318→CREATE POLICY "Involved parties can delete matches"
   319→  ON matches FOR DELETE
   320→  USING (
   321→    (
   322→      worker_id = auth.uid()
   323→      OR job_id IN (
   324→        SELECT j.id FROM jobs j
   325→        JOIN companies c ON j.company_id = c.id
   326→        WHERE c.profile_id = auth.uid()
   327→      )
   328→    )
   329→    AND status NOT IN ('hired')
   330→  );
   331→
   332→COMMIT;
   333→

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


Bash

test -f /home/ccuser/the-50-dollar-app/src/lib/voice-complete-test.ts && echo "FILE STILL EXISTS" || echo "FILE DELETED SUCCESSFULLY"

Verify test file was deleted


FILE DELETED SUCCESSFULLY


All three changes are verified. Here is a summary of everything that was done:

---

## Changes Made

### 1. Fixed notification subscription filters in `/home/ccuser/the-50-dollar-app/src/hooks/use-notifications.ts`

**`useNotifications` hook:**
- Added `userId` state variable (line 42): `const [userId, setUserId] = useState<string | null>(null);`
- Set `userId` from auth user during fetch (line 59): `setUserId(user.id);` and clear it on logout (line 54): `setUserId(null);`
- Added early return guard in the subscription effect (line 148): `if (!userId) return;` -- this prevents subscribing before the user is known
- Added `filter: "profile_id=eq." + userId` to all three subscription event handlers (INSERT at line 163, UPDATE at line 183, DELETE at line 199) so the client only receives realtime events for the current user's notifications
- Added `userId` to the effect's dependency array (line 216)

**`useUnreadCount` hook:**
- Same pattern: added `userId` state (line 242), set it from auth (line 256), clear on no user (line 252)
- Added `if (!userId) return;` guard (line 283)
- Added `filter: "profile_id=eq." + userId` to both INSERT (line 298) and UPDATE (line 308) subscription handlers
- Added `userId` to the effect's dependency array (line 321)

### 2. Deleted test file from production

Removed `/home/ccuser/the-50-dollar-app/src/lib/voice-complete-test.ts` -- this was a manual test helper file with test interfaces and a `testVoiceCompleteEndpoint()` function that should not ship in production.

### 3. Created Supabase migration at `/home/ccuser/the-50-dollar-app/supabase/migrations/20260210100000_fix_security_definer_search_path.sql`

This migration fixes several security issues:

- **`find_matching_workers`** -- Recreated with `SET search_path = public` appended to the `SECURITY DEFINER` declaration (line 195-196). Function signature and body preserved exactly from the original in `20260210000002_add_display_name.sql`.

- **`get_cached_abn`** -- Recreated with `SET search_path = public` (line 245-246). Signature and body match the original in `20260210000001_create_abn_cache_table.sql`.

- **`get_abn_cache_stats`** -- Recreated with `SET search_path = public` (line 270-271).

- **`increment_abn_lookup`** -- Recreated with `SET search_path = public` (line 286-287).

- **`cleanup_expired_abn_cache`** -- Recreated with `SET search_path = public` (line 304-305).

- **Payment DELETE policy dropped** (line 311): `DROP POLICY IF EXISTS "Company owners can delete their payments" ON payments;` -- removes the overly permissive policy that allowed company owners to delete payment records.

- **Match deletion restricted** (lines 317-330): Drops the existing unrestricted `"Involved parties can delete matches"` policy and recreates it with an additional `AND status NOT IN ('hired')` condition, preventing deletion of matches that are already in the `hired` state.