Thoroughly explore the Supabase setup in /home/ccuser/the-50-dollar-app/supabase/

1. Read ALL migration files in supabase/migrations/
2. Check if the required RPC functions exist: find_matching_workers, get_or_create_conversation, get_conversation_previews
3. Check RLS policies
4. Read supabase/config.toml if it exists
5. Look for any seed files
6. Check if migrations match the types defined in src/lib/types.ts

Report any missing tables, missing RPC functions, broken migrations, or schema mismatches.


I'll thoroughly explore the Supabase setup in the specified directory. Let me start by examining the structure and then diving into the details.


Bash

find /home/ccuser/the-50-dollar-app/supabase -type f -name "*.sql" -o -name "*.toml" -o -name "*.json" 2>/dev/null | head -50

Find all relevant Supabase files (migrations, configs)


ls -la /home/ccuser/the-50-dollar-app/supabase/ 2>/dev/null

List Supabase directory structure


total 68
drwxrwxr-x  4 ccuser ccuser  4096 Feb  8 11:18 .
drwxrwxr-x 10 ccuser ccuser  4096 Feb  8 12:04 ..
drwxrwxr-x  2 ccuser ccuser  4096 Feb  8 11:18 .temp
-rw-rw-r--  1 ccuser ccuser  5952 Feb  8 21:31 add-messaging.sql
-rw-rw-r--  1 ccuser ccuser  5973 Feb  8 11:18 fix-critical-rls-insert-policies.sql
-rw-rw-r--  1 ccuser ccuser  5951 Feb  8 11:18 fix-rls-policies-migration.sql
drwxrwxr-x  2 ccuser ccuser  4096 Feb  8 22:09 migrations
-rw-rw-r--  1 ccuser ccuser 20558 Feb  8 21:37 schema.sql
-rw-rw-r--  1 ccuser ccuser  2939 Feb  8 11:18 test-rls-policies.sql


Now let me read the migration files and check the schema:


     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);
   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→-- Migration: Add DB-side match scoring RPC function
     2→-- Created: 2025-01-21
     3→-- Description: Moves match scoring from application layer to database for better scalability
     4→-- This allows efficient matching even with 10k+ workers
     5→
     6→-- ============================================
     7→-- RPC FUNCTION: find_matching_workers
     8→-- ============================================
     9→
    10→CREATE OR REPLACE FUNCTION find_matching_workers(
    11→  p_job_id uuid,
    12→  p_limit integer DEFAULT 20,
    13→  p_min_score integer DEFAULT 20
    14→)
    15→RETURNS TABLE (
    16→  worker_id uuid,
    17→  score integer,
    18→  summary text,
    19→  reasons text[],
    20→  profile jsonb
    21→) AS $$
    22→DECLARE
    23→  v_job_record RECORD;
    24→  v_job_location point;
    25→BEGIN
    26→  -- Get job details once
    27→  SELECT
    28→    j.id,
    29→    j.trade,
    30→    j.certifications_required,
    31→    j.start_date,
    32→    j.duration,
    33→    j.suburb,
    34→    j.location,
    35→    j.company_id
    36→  INTO v_job_record
    37→  FROM jobs j
    38→  WHERE j.id = p_job_id AND j.status = 'active';
    39→
    40→  -- Return empty if job not found or not active
    41→  IF v_job_record IS NULL THEN
    42→    RETURN;
    43→  END IF;
    44→
    45→  v_job_location := v_job_record.location;
    46→
    47→  RETURN QUERY
    48→  WITH
    49→  -- Get existing matches to exclude
    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→  -- Calculate average ratings per worker
    56→  worker_ratings AS (
    57→    SELECT
    58→      r.to_id AS rating_worker_id,
    59→      AVG(r.score)::decimal AS avg_rating,
    60→      COUNT(*)::integer AS rating_count
    61→    FROM ratings r
    62→    GROUP BY r.to_id
    63→  ),
    64→  -- Score each worker
    65→  scored_workers AS (
    66→    SELECT
    67→      p.id AS worker_profile_id,
    68→      p.name AS worker_name,
    69→      p.suburb AS worker_suburb,
    70→      p.location AS worker_location,
    71→      wp.trades AS worker_trades,
    72→      wp.availability AS worker_availability,
    73→      wp.experience_years,
    74→      wp.certifications AS worker_certs,
    75→      wp.bio,
    76→      wp.white_card_verified,
    77→      -- Trade match score (40 points max)
    78→      CASE
    79→        WHEN v_job_record.trade = ANY(wp.trades) THEN 40
    80→        WHEN 'Labourer' = ANY(wp.trades) THEN 10
    81→        -- Related trade groups
    82→        WHEN v_job_record.trade IN ('Steel Fixer', 'Formworker', 'Concreter', 'Scaffolder')
    83→          AND wp.trades && ARRAY['Steel Fixer', 'Formworker', 'Concreter', 'Scaffolder']::text[] THEN 15
    84→        WHEN v_job_record.trade IN ('Painter', 'Tiler', 'Plasterer')
    85→          AND wp.trades && ARRAY['Painter', 'Tiler', 'Plasterer']::text[] THEN 15
    86→        WHEN v_job_record.trade IN ('Electrician', 'Plumber')
    87→          AND wp.trades && ARRAY['Electrician', 'Plumber']::text[] THEN 15
    88→        WHEN v_job_record.trade IN ('Crane Operator', 'Excavator Operator')
    89→          AND wp.trades && ARRAY['Crane Operator', 'Excavator Operator']::text[] THEN 15
    90→        WHEN v_job_record.trade IN ('Bricklayer', 'Roofer', 'Welder')
    91→          AND wp.trades && ARRAY['Bricklayer', 'Roofer', 'Welder']::text[] THEN 15
    92→        ELSE 0
    93→      END AS trade_score,
    94→      -- Availability score (20 points max)
    95→      CASE wp.availability
    96→        WHEN 'flexible' THEN 20
    97→        WHEN 'full-time' THEN 18
    98→        WHEN 'casual' THEN 12
    99→        WHEN 'weekends' THEN 8
   100→        ELSE 0
   101→      END AS availability_score,
   102→      -- Experience score (15 points max)
   103→      CASE
   104→        WHEN wp.experience_years >= 10 THEN 15
   105→        WHEN wp.experience_years >= 7 THEN 13
   106→        WHEN wp.experience_years >= 4 THEN 11
   107→        WHEN wp.experience_years >= 2 THEN 8
   108→        WHEN wp.experience_years >= 1 THEN 5
   109→        WHEN wp.experience_years IS NOT NULL THEN 2
   110→        ELSE 0
   111→      END AS experience_score,
   112→      -- Certifications score (15 points max)
   113→      CASE
   114→        WHEN array_length(v_job_record.certifications_required, 1) IS NULL
   115→          OR array_length(v_job_record.certifications_required, 1) = 0 THEN 15
   116→        ELSE
   117→          ROUND(
   118→            (
   119→              COALESCE(
   120→                array_length(
   121→                  ARRAY(
   122→                    SELECT unnest(v_job_record.certifications_required)
   123→                    INTERSECT
   124→                    SELECT unnest(wp.certifications)
   125→                  ),
   126→                  1
   127→                ),
   128→                0
   129→              )::decimal /
   130→              array_length(v_job_record.certifications_required, 1)
   131→            ) * 15
   132→          )
   133→      END AS cert_score,
   134→      -- Location score (10 points max) - uses point distance
   135→      CASE
   136→        WHEN v_job_location IS NULL OR p.location IS NULL THEN 0
   137→        WHEN p.location <-> v_job_location < 0.01 THEN 10  -- Same area (~1km)
   138→        WHEN p.location <-> v_job_location < 0.05 THEN 7   -- Nearby (~5km)
   139→        WHEN p.location <-> v_job_location < 0.1 THEN 5    -- Same region (~10km)
   140→        WHEN p.suburb = v_job_record.suburb THEN 10        -- Exact suburb match (fallback)
   141→        WHEN p.suburb ILIKE '%' || v_job_record.suburb || '%'
   142→          OR v_job_record.suburb ILIKE '%' || p.suburb || '%' THEN 7
   143→        ELSE 0
   144→      END AS location_score,
   145→      -- Rating score (10 points max)
   146→      COALESCE(
   147→        ROUND((wr.avg_rating / 5.0) * 10),
   148→        0
   149→      ) AS rating_score,
   150→      wr.avg_rating,
   151→      wr.rating_count
   152→    FROM profiles p
   153→    INNER JOIN worker_profiles wp ON wp.profile_id = p.id
   154→    LEFT JOIN worker_ratings wr ON wr.rating_worker_id = p.id
   155→    WHERE
   156→      p.onboarding_completed = true
   157→      AND p.type = 'worker'
   158→      AND p.id NOT IN (SELECT excluded_worker_id FROM existing_matches)
   159→  ),
   160→  -- Calculate total score and build reasons
   161→  final_scores AS (
   162→    SELECT
   163→      sw.*,
   164→      (sw.trade_score + sw.availability_score + sw.experience_score +
   165→       sw.cert_score + sw.location_score + sw.rating_score) AS total_score,
   166→      -- Build reasons array
   167→      ARRAY_REMOVE(ARRAY[
   168→        CASE WHEN sw.trade_score = 40 THEN 'Trades as ' || v_job_record.trade
   169→             WHEN sw.trade_score = 15 THEN 'Related trade experience'
   170→             WHEN sw.trade_score = 10 THEN 'General labourer available'
   171→             ELSE NULL END,
   172→        CASE WHEN sw.availability_score > 0 THEN 'Available ' || sw.worker_availability ELSE NULL END,
   173→        CASE WHEN sw.experience_score > 0 THEN sw.experience_years || '+ years experience' ELSE NULL END,
   174→        CASE WHEN sw.cert_score > 0 AND array_length(v_job_record.certifications_required, 1) > 0
   175→             THEN 'Has required certifications' ELSE NULL END,
   176→        CASE WHEN sw.location_score > 0 THEN 'Based in ' || sw.worker_suburb ELSE NULL END,
   177→        CASE WHEN sw.rating_score >= 8 THEN ROUND(sw.avg_rating::numeric, 1) || ' star rating' ELSE NULL END
   178→      ], NULL) AS match_reasons
   179→    FROM scored_workers sw
   180→  )
   181→  SELECT
   182→    fs.worker_profile_id,
   183→    LEAST(fs.total_score, 100) AS score,
   184→    array_to_string(fs.match_reasons[:3], '. ') || '.' AS summary,
   185→    fs.match_reasons AS reasons,
   186→    jsonb_build_object(
   187→      'id', fs.worker_profile_id,
   188→      'name', fs.worker_name,
   189→      'suburb', fs.worker_suburb,
   190→      'trades', fs.worker_trades,
   191→      'experience_years', fs.experience_years,
   192→      'availability', fs.worker_availability,
   193→      'certifications', fs.worker_certs,
   194→      'bio', fs.bio,
   195→      'white_card_verified', fs.white_card_verified,
   196→      'avg_rating', COALESCE(fs.avg_rating, 0),
   197→      'rating_count', COALESCE(fs.rating_count, 0)
   198→    ) AS profile
   199→  FROM final_scores fs
   200→  WHERE fs.total_score >= p_min_score
   201→  ORDER BY fs.total_score DESC, fs.experience_years DESC NULLS LAST
   202→  LIMIT p_limit;
   203→END;
   204→$$ LANGUAGE plpgsql SECURITY DEFINER;
   205→
   206→-- Grant execute permission to authenticated users
   207→GRANT EXECUTE ON FUNCTION find_matching_workers(uuid, integer, integer) TO authenticated;
   208→GRANT EXECUTE ON FUNCTION find_matching_workers(uuid, integer, integer) TO service_role;
   209→
   210→-- Create index on profiles.location for efficient distance calculations
   211→CREATE INDEX IF NOT EXISTS idx_profiles_location ON profiles USING gist (location) WHERE type = 'worker';
   212→
   213→-- Create index to speed up the worker lookup
   214→CREATE INDEX IF NOT EXISTS idx_worker_profiles_lookup ON worker_profiles (profile_id, availability, experience_years) INCLUDE (trades, certifications, bio);
   215→
   216→-- ============================================
   217→-- VERIFICATION QUERIES
   218→-- ============================================
   219→
   220→-- Test the function (replace with actual job_id)
   221→-- SELECT * FROM find_matching_workers('your-job-id-here'::uuid, 20, 20);
   222→
   223→-- Check function exists
   224→-- SELECT proname, proargnames, prorettype::regtype
   225→-- FROM pg_proc
   226→-- WHERE proname = 'find_matching_workers';
   227→

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


     1→-- Migration: Add ABN uniqueness constraint to companies table
     2→-- Created: 2026-02-08
     3→-- Description: Ensures ABN values are unique across all companies
     4→--
     5→-- IMPORTANT: This migration requires manual intervention if duplicates exist.
     6→-- The migration will FAIL if duplicate ABN values exist in the database.
     7→--
     8→-- BEFORE RUNNING:
     9→-- 1. Execute the verification queries to check for duplicates
    10→-- 2. If duplicates exist, resolve them (nullify or update manually)
    11→-- 3. Then run this migration
    12→--
    13→-- AFTER RUNNING:
    14→-- 1. Verify the constraint is working with the post-migration queries
    15→
    16→-- ============================================
    17→-- VERIFICATION QUERIES (Run these before migration)
    18→-- ============================================
    19→
    20→-- Check for existing duplicate ABN values
    21→-- If duplicates exist, resolve them before running this migration
    22→/*
    23→SELECT abn, COUNT(*) as duplicate_count, array_agg(id) as company_ids
    24→FROM companies
    25→WHERE abn IS NOT NULL
    26→GROUP BY abn
    27→HAVING COUNT(*) > 1;
    28→*/
    29→
    30→-- Check total companies with ABN values
    31→/*
    32→SELECT COUNT(*) as total_companies_with_abn
    33→FROM companies
    34→WHERE abn IS NOT NULL;
    35→*/
    36→
    37→-- ============================================
    38→-- UP MIGRATION
    39→-- ============================================
    40→
    41→-- Step 1: Handle existing duplicate ABN values
    42→-- Option A: Nullify duplicate ABNs (safer than deletion)
    43→-- This preserves all companies but removes ABN from duplicates
    44→/*
    45→UPDATE companies
    46→SET abn = NULL, updated_at = now()
    47→WHERE id IN (
    48→  SELECT id FROM (
    49→    SELECT id, ROW_NUMBER() OVER (PARTITION BY abn ORDER BY updated_at DESC) as rn
    50→    FROM companies
    51→    WHERE abn IS NOT NULL
    52→  ) ranked
    53→  WHERE rn > 1
    54→);
    55→*/
    56→
    57→-- Option B: Manually review and handle duplicates
    58→-- Use the verification query above to identify duplicates
    59→-- Then manually update them through the app or direct SQL
    60→
    61→-- Step 2: Add unique constraint to ABN column
    62→-- This will fail if duplicates exist - ensure Step 1 is completed first
    63→ALTER TABLE companies
    64→ADD CONSTRAINT companies_abn_unique UNIQUE (abn);
    65→
    66→-- Step 3: Create index on ABN for better query performance
    67→-- This index already exists in the schema but we'll ensure it exists
    68→CREATE INDEX IF NOT EXISTS idx_companies_abn ON companies (abn);
    69→
    70→-- ============================================
    71→-- DOWN MIGRATION
    72→-- ============================================
    73→
    74→-- To rollback this migration, run the following:
    75→
    76→-- Remove the unique constraint
    77→-- ALTER TABLE companies
    78→-- DROP CONSTRAINT IF EXISTS companies_abn_unique;
    79→
    80→-- Note: The index idx_companies_abn is kept for performance even after rollback
    81→-- To also remove the index (not recommended):
    82→-- DROP INDEX IF EXISTS idx_companies_abn;
    83→
    84→-- ============================================
    85→-- POST-MIGRATION VERIFICATION
    86→-- ============================================
    87→
    88→-- Verify the constraint is working (should return 0)
    89→/*
    90→SELECT COUNT(*) as constraint_violations
    91→FROM (
    92→  SELECT abn, COUNT(*) as count
    93→  FROM companies
    94→  WHERE abn IS NOT NULL
    95→  GROUP BY abn
    96→  HAVING COUNT(*) > 1
    97→) as duplicates;
    98→*/
    99→
   100→-- Test inserting a duplicate ABN (should fail)
   101→/*
   102→-- This should fail with unique constraint violation
   103→INSERT INTO companies (profile_id, name, abn)
   104→VALUES ('test-profile-id', 'Test Company', '12345678901');
   105→*/
   106→
   107→-- List all unique ABNs
   108→/*
   109→SELECT DISTINCT abn
   110→FROM companies
   111→WHERE abn IS NOT NULL
   112→ORDER BY abn;
   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→-- Fix messaging functions: add SET search_path = public to prevent SQL injection
     2→-- These were created in add-messaging.sql without search_path protection
     3→
     4→CREATE OR REPLACE FUNCTION get_conversation_previews(
     5→  user_id uuid,
     6→  page_limit integer DEFAULT 20,
     7→  page_offset integer DEFAULT 0
     8→)
     9→RETURNS TABLE (
    10→  conversation_id uuid,
    11→  other_participant_name text,
    12→  other_participant_avatar text,
    13→  last_message text,
    14→  last_message_time timestamptz,
    15→  unread_count bigint,
    16→  other_participant_is_online boolean
    17→) AS $$
    18→BEGIN
    19→  RETURN QUERY
    20→  SELECT
    21→    c.id as conversation_id,
    22→    p.name as other_participant_name,
    23→    p.avatar_url as other_participant_avatar,
    24→    last_msg.content as last_message,
    25→    last_msg.created_at as last_message_time,
    26→    COALESCE(unread.unread_count, 0) as unread_count,
    27→    true as other_participant_is_online -- TODO: Implement online status
    28→  FROM conversations c
    29→  INNER JOIN conversation_participants cp ON c.id = cp.conversation_id
    30→  INNER JOIN conversation_participants other_cp ON c.id = other_cp.conversation_id
    31→  INNER JOIN profiles p ON other_cp.profile_id = p.id
    32→  LEFT JOIN LATERAL (
    33→    SELECT content, created_at
    34→    FROM messages
    35→    WHERE conversation_id = c.id
    36→    ORDER BY created_at DESC
    37→    LIMIT 1
    38→  ) last_msg ON true
    39→  LEFT JOIN LATERAL (
    40→    SELECT COUNT(*) as unread_count
    41→    FROM messages
    42→    WHERE conversation_id = c.id
    43→      AND sender_id != user_id
    44→      AND created_at > cp.last_read_at
    45→  ) unread ON true
    46→  WHERE cp.profile_id = user_id
    47→    AND other_cp.profile_id != user_id
    48→  ORDER BY last_msg.created_at DESC NULLS LAST
    49→  LIMIT page_limit
    50→  OFFSET page_offset;
    51→END;
    52→$$ LANGUAGE plpgsql STABLE
    53→SET search_path = public;
    54→
    55→CREATE OR REPLACE FUNCTION get_or_create_conversation(user1_id uuid, user2_id uuid)
    56→RETURNS uuid AS $$
    57→DECLARE
    58→  conversation_uuid uuid;
    59→BEGIN
    60→  -- Check if conversation already exists
    61→  SELECT c.id INTO conversation_uuid
    62→  FROM conversations c
    63→  INNER JOIN conversation_participants cp1 ON c.id = cp1.conversation_id
    64→  INNER JOIN conversation_participants cp2 ON c.id = cp2.conversation_id
    65→  WHERE cp1.profile_id = user1_id
    66→    AND cp2.profile_id = user2_id
    67→  LIMIT 1;
    68→
    69→  -- If conversation doesn't exist, create it
    70→  IF conversation_uuid IS NULL THEN
    71→    INSERT INTO conversations DEFAULT VALUES
    72→    RETURNING id INTO conversation_uuid;
    73→
    74→    INSERT INTO conversation_participants (conversation_id, profile_id)
    75→    VALUES (conversation_uuid, user1_id), (conversation_uuid, user2_id);
    76→  END IF;
    77→
    78→  RETURN conversation_uuid;
    79→END;
    80→$$ LANGUAGE plpgsql
    81→SET search_path = public;
    82→

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


Let me continue reading the remaining migration files:


     1→-- Add missing indexes on payments table for query performance
     2→CREATE INDEX IF NOT EXISTS idx_payments_match_id ON payments (match_id);
     3→CREATE INDEX IF NOT EXISTS idx_payments_company_id ON payments (company_id);
     4→

<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 last_seen_at to profiles for online status tracking
     2→ALTER TABLE profiles ADD COLUMN IF NOT EXISTS last_seen_at timestamptz;
     3→CREATE INDEX IF NOT EXISTS idx_profiles_last_seen_at ON profiles (last_seen_at);
     4→
     5→-- Update get_conversation_previews to use real online status (active in last 5 min)
     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→) AS $$
    20→BEGIN
    21→  RETURN QUERY
    22→  SELECT
    23→    c.id as conversation_id,
    24→    p.name as other_participant_name,
    25→    p.avatar_url as other_participant_avatar,
    26→    last_msg.content as last_message,
    27→    last_msg.created_at as last_message_time,
    28→    COALESCE(unread.unread_count, 0) as unread_count,
    29→    COALESCE(p.last_seen_at > (now() - interval '5 minutes'), false) as other_participant_is_online
    30→  FROM conversations c
    31→  INNER JOIN conversation_participants cp ON c.id = cp.conversation_id
    32→  INNER JOIN conversation_participants other_cp ON c.id = other_cp.conversation_id
    33→  INNER JOIN profiles p ON other_cp.profile_id = p.id
    34→  LEFT JOIN LATERAL (
    35→    SELECT content, created_at
    36→    FROM messages
    37→    WHERE conversation_id = c.id
    38→    ORDER BY created_at DESC
    39→    LIMIT 1
    40→  ) last_msg ON true
    41→  LEFT JOIN LATERAL (
    42→    SELECT COUNT(*) as unread_count
    43→    FROM messages
    44→    WHERE conversation_id = c.id
    45→      AND sender_id != user_id
    46→      AND created_at > cp.last_read_at
    47→  ) unread ON true
    48→  WHERE cp.profile_id = user_id
    49→    AND other_cp.profile_id != user_id
    50→  ORDER BY last_msg.created_at DESC NULLS LAST
    51→  LIMIT page_limit
    52→  OFFSET page_offset;
    53→END;
    54→$$ LANGUAGE plpgsql STABLE
    55→SET search_path = public;
    56→

<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→-- Create avatars storage bucket for user profile photos
     2→-- Bucket config: 5MB max, public, images only
     3→
     4→INSERT INTO storage.buckets (id, name, public, file_size_limit, allowed_mime_types)
     5→VALUES (
     6→  'avatars',
     7→  'avatars',
     8→  true,
     9→  5242880, -- 5MB
    10→  '{"image/jpeg","image/png","image/webp","image/gif"}'
    11→)
    12→ON CONFLICT (id) DO NOTHING;
    13→
    14→-- Drop existing policies if they exist
    15→DROP POLICY IF EXISTS "Avatars are publicly accessible" ON storage.objects;
    16→DROP POLICY IF EXISTS "Users can upload their own avatar" ON storage.objects;
    17→DROP POLICY IF EXISTS "Users can update their own avatar" ON storage.objects;
    18→DROP POLICY IF EXISTS "Users can delete their own avatar" ON storage.objects;
    19→
    20→-- Allow anyone to read avatars (public bucket)
    21→CREATE POLICY "Avatars are publicly accessible"
    22→ON storage.objects FOR SELECT
    23→USING (bucket_id = 'avatars');
    24→
    25→-- Allow authenticated users to upload their own avatar
    26→-- Files must be stored in a folder named after their user ID
    27→CREATE POLICY "Users can upload their own avatar"
    28→ON storage.objects FOR INSERT
    29→WITH CHECK (
    30→  bucket_id = 'avatars'
    31→  AND auth.uid()::text = (storage.foldername(name))[1]
    32→);
    33→
    34→-- Allow users to update their own avatar
    35→CREATE POLICY "Users can update their own avatar"
    36→ON storage.objects FOR UPDATE
    37→USING (
    38→  bucket_id = 'avatars'
    39→  AND auth.uid()::text = (storage.foldername(name))[1]
    40→);
    41→
    42→-- Allow users to delete their own avatar
    43→CREATE POLICY "Users can delete their own avatar"
    44→ON storage.objects FOR DELETE
    45→USING (
    46→  bucket_id = 'avatars'
    47→  AND auth.uid()::text = (storage.foldername(name))[1]
    48→);
    49→

<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 unique constraint on companies.abn
     2→-- Created: 2026-02-09
     3→
     4→-- Step 1: Check for existing duplicate ABNs
     5→-- This query will show all companies that have duplicate ABN values
     6→-- (excluding NULL and empty string ABNs)
     7→SELECT
     8→    abn,
     9→    COUNT(*) as duplicate_count,
    10→    array_agg(
    11→        json_build_object(
    12→            'id', id,
    13→            'name', name,
    14→            'email', email
    15→        )
    16→    ) as companies
    17→FROM companies
    18→WHERE abn IS NOT NULL
    19→  AND abn != ''
    20→GROUP BY abn
    21→HAVING COUNT(*) > 1
    22→ORDER BY duplicate_count DESC, abn;
    23→
    24→-- Step 2: Migration to add UNIQUE constraint
    25→-- Note: This migration uses a partial unique index to exclude NULL and empty ABNs
    26→BEGIN;
    27→
    28→-- Create a partial unique index on ABN
    29→-- This allows multiple NULL values and multiple empty strings, but enforces uniqueness on actual ABN values
    30→CREATE UNIQUE INDEX companies_abn_unique
    31→ON companies (abn)
    32→WHERE abn IS NOT NULL AND abn != '';
    33→
    34→-- Add a comment to document the constraint
    35→COMMENT ON INDEX companies_abn_unique IS 'Ensures ABN values are unique across companies, excluding NULL and empty values';
    36→
    37→COMMIT;
    38→
    39→-- Notes on handling duplicates:
    40→-- If the duplicate check query returns results, you'll need to resolve these before running the migration.
    41→-- Options for resolving duplicates:
    42→-- 1. Update duplicate ABNs to NULL or empty string for companies that don't need an ABN
    43→-- 2. Update ABNs to correct unique values where data entry errors occurred
    44→-- 3. Merge duplicate company records if they represent the same business
    45→-- 4. Add a suffix to make ABNs unique temporarily (e.g., '12345678901_OLD')
    46→--
    47→-- Example resolution queries:
    48→-- UPDATE companies SET abn = NULL WHERE id = <duplicate_id>;
    49→-- UPDATE companies SET abn = <correct_abn> WHERE id = <company_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>


Now let me read the remaining migration files:


     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>


     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→CREATE POLICY "Users can delete their own profile"
    12→  ON profiles FOR DELETE
    13→  USING (auth.uid() = id);
    14→
    15→-- ============================================
    16→-- companies: Contractors can delete their own company
    17→-- ============================================
    18→CREATE POLICY "Contractors can delete their own company"
    19→  ON companies FOR DELETE
    20→  USING (profile_id = auth.uid());
    21→
    22→-- ============================================
    23→-- worker_profiles: Workers can delete their own profile
    24→-- ============================================
    25→CREATE POLICY "Workers can delete their own profile"
    26→  ON worker_profiles FOR DELETE
    27→  USING (profile_id = auth.uid());
    28→
    29→-- ============================================
    30→-- jobs: Contractors can delete their own jobs
    31→-- ============================================
    32→CREATE POLICY "Contractors can delete their own jobs"
    33→  ON jobs FOR DELETE
    34→  USING (company_id IN (
    35→    SELECT id FROM companies WHERE profile_id = auth.uid()
    36→  ));
    37→
    38→-- ============================================
    39→-- matches: Involved parties can delete matches
    40→-- Worker who applied OR contractor who owns the job
    41→-- ============================================
    42→CREATE POLICY "Involved parties can delete matches"
    43→  ON matches FOR DELETE
    44→  USING (
    45→    worker_id = auth.uid()
    46→    OR job_id IN (
    47→      SELECT j.id FROM jobs j
    48→      JOIN companies c ON j.company_id = c.id
    49→      WHERE c.profile_id = auth.uid()
    50→    )
    51→  );
    52→
    53→-- ============================================
    54→-- ratings: Users can delete ratings they authored
    55→-- ============================================
    56→CREATE POLICY "Users can delete their own ratings"
    57→  ON ratings FOR DELETE
    58→  USING (from_id = auth.uid());
    59→
    60→-- ============================================
    61→-- payments: Company owners can delete their payments
    62→-- ============================================
    63→CREATE POLICY "Company owners can delete their payments"
    64→  ON payments FOR DELETE
    65→  USING (company_id IN (
    66→    SELECT id FROM companies WHERE profile_id = auth.uid()
    67→  ));
    68→
    69→-- ============================================
    70→-- notifications: Users can delete their own notifications
    71→-- ============================================
    72→CREATE POLICY "Users can delete their own notifications"
    73→  ON notifications FOR DELETE
    74→  USING (profile_id = auth.uid());
    75→
    76→-- ============================================
    77→-- conversations: Participants can delete conversations they belong to
    78→-- ============================================
    79→CREATE POLICY "Users can delete their own conversations"
    80→  ON conversations FOR DELETE
    81→  USING (id IN (
    82→    SELECT conversation_id FROM conversation_participants
    83→    WHERE profile_id = auth.uid()
    84→  ));
    85→
    86→-- ============================================
    87→-- conversation_participants: Users can remove themselves from conversations
    88→-- ============================================
    89→CREATE POLICY "Users can delete their own participation"
    90→  ON conversation_participants FOR DELETE
    91→  USING (profile_id = auth.uid());
    92→
    93→-- ============================================
    94→-- messages: Users can delete their own messages
    95→-- ============================================
    96→CREATE POLICY "Users can delete their own messages"
    97→  ON messages FOR DELETE
    98→  USING (sender_id = auth.uid());
    99→
   100→COMMIT;
   101→

<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→-- Messaging system for RateRight v2
     2→-- Creates tables for conversations and messages
     3→
     4→-- Conversations table to group messages between users
     5→CREATE TABLE conversations (
     6→  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
     7→  created_at timestamptz DEFAULT now(),
     8→  updated_at timestamptz DEFAULT now()
     9→);
    10→
    11→-- Conversation participants
    12→CREATE TABLE conversation_participants (
    13→  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
    14→  conversation_id uuid NOT NULL REFERENCES conversations ON DELETE CASCADE,
    15→  profile_id uuid NOT NULL REFERENCES profiles ON DELETE CASCADE,
    16→  last_read_at timestamptz,
    17→  created_at timestamptz DEFAULT now(),
    18→  UNIQUE (conversation_id, profile_id)
    19→);
    20→
    21→-- Messages table
    22→CREATE TABLE messages (
    23→  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
    24→  conversation_id uuid NOT NULL REFERENCES conversations ON DELETE CASCADE,
    25→  sender_id uuid NOT NULL REFERENCES profiles ON DELETE CASCADE,
    26→  content text NOT NULL,
    27→  attachments jsonb DEFAULT '[]',
    28→  status text DEFAULT 'sent' CHECK (status IN ('sent', 'delivered', 'read')),
    29→  created_at timestamptz DEFAULT now(),
    30→  updated_at timestamptz DEFAULT now()
    31→);
    32→
    33→-- Indexes for performance
    34→CREATE INDEX idx_conversation_participants_conversation_id ON conversation_participants (conversation_id);
    35→CREATE INDEX idx_conversation_participants_profile_id ON conversation_participants (profile_id);
    36→CREATE INDEX idx_messages_conversation_id ON messages (conversation_id);
    37→CREATE INDEX idx_messages_sender_id ON messages (sender_id);
    38→CREATE INDEX idx_messages_created_at ON messages (created_at DESC);
    39→
    40→-- Row Level Security
    41→ALTER TABLE conversations ENABLE ROW LEVEL SECURITY;
    42→ALTER TABLE conversation_participants ENABLE ROW LEVEL SECURITY;
    43→ALTER TABLE messages ENABLE ROW LEVEL SECURITY;
    44→
    45→-- Conversations: participants can view their conversations
    46→CREATE POLICY "Users can view their own conversations"
    47→  ON conversations FOR SELECT
    48→  USING (
    49→    id IN (
    50→      SELECT conversation_id FROM conversation_participants
    51→      WHERE profile_id = auth.uid()
    52→    )
    53→  );
    54→
    55→-- Conversation participants: participants can view participants of their conversations
    56→CREATE POLICY "Users can view participants of their conversations"
    57→  ON conversation_participants FOR SELECT
    58→  USING (
    59→    conversation_id IN (
    60→      SELECT conversation_id FROM conversation_participants
    61→      WHERE profile_id = auth.uid()
    62→    )
    63→  );
    64→
    65→CREATE POLICY "Users can insert conversation participants"
    66→  ON conversation_participants FOR INSERT
    67→  WITH CHECK (profile_id = auth.uid());
    68→
    69→CREATE POLICY "Users can update their own participant record"
    70→  ON conversation_participants FOR UPDATE
    71→  USING (profile_id = auth.uid());
    72→
    73→-- Messages: participants can view messages in their conversations
    74→CREATE POLICY "Users can view messages in their conversations"
    75→  ON messages FOR SELECT
    76→  USING (
    77→    conversation_id IN (
    78→      SELECT conversation_id FROM conversation_participants
    79→      WHERE profile_id = auth.uid()
    80→    )
    81→  );
    82→
    83→CREATE POLICY "Users can send messages in their conversations"
    84→  ON messages FOR INSERT
    85→  WITH CHECK (
    86→    sender_id = auth.uid() AND
    87→    conversation_id IN (
    88→      SELECT conversation_id FROM conversation_participants
    89→      WHERE profile_id = auth.uid()
    90→    )
    91→  );
    92→
    93→CREATE POLICY "Users can update their own messages"
    94→  ON messages FOR UPDATE
    95→  USING (sender_id = auth.uid());
    96→
    97→-- Update updated_at trigger for messages
    98→CREATE TRIGGER messages_updated_at
    99→  BEFORE UPDATE ON messages
   100→  FOR EACH ROW EXECUTE FUNCTION update_updated_at();
   101→
   102→-- Function to get conversation preview data
   103→CREATE OR REPLACE FUNCTION get_conversation_previews(
   104→  user_id uuid,
   105→  page_limit integer DEFAULT 20,
   106→  page_offset integer DEFAULT 0
   107→)
   108→RETURNS TABLE (
   109→  conversation_id uuid,
   110→  other_participant_name text,
   111→  other_participant_avatar text,
   112→  last_message text,
   113→  last_message_time timestamptz,
   114→  unread_count bigint,
   115→  other_participant_is_online boolean
   116→) AS $$
   117→BEGIN
   118→  RETURN QUERY
   119→  SELECT
   120→    c.id as conversation_id,
   121→    p.name as other_participant_name,
   122→    p.avatar_url as other_participant_avatar,
   123→    last_msg.content as last_message,
   124→    last_msg.created_at as last_message_time,
   125→    COALESCE(unread.unread_count, 0) as unread_count,
   126→    true as other_participant_is_online -- TODO: Implement online status
   127→  FROM conversations c
   128→  INNER JOIN conversation_participants cp ON c.id = cp.conversation_id
   129→  INNER JOIN conversation_participants other_cp ON c.id = other_cp.conversation_id
   130→  INNER JOIN profiles p ON other_cp.profile_id = p.id
   131→  LEFT JOIN LATERAL (
   132→    SELECT content, created_at
   133→    FROM messages
   134→    WHERE conversation_id = c.id
   135→    ORDER BY created_at DESC
   136→    LIMIT 1
   137→  ) last_msg ON true
   138→  LEFT JOIN LATERAL (
   139→    SELECT COUNT(*) as unread_count
   140→    FROM messages
   141→    WHERE conversation_id = c.id
   142→      AND sender_id != user_id
   143→      AND created_at > cp.last_read_at
   144→  ) unread ON true
   145→  WHERE cp.profile_id = user_id
   146→    AND other_cp.profile_id != user_id
   147→  ORDER BY last_msg.created_at DESC NULLS LAST
   148→  LIMIT page_limit
   149→  OFFSET page_offset;
   150→END;
   151→$$ LANGUAGE plpgsql STABLE;
   152→
   153→-- Function to create or get existing conversation between two users
   154→CREATE OR REPLACE FUNCTION get_or_create_conversation(user1_id uuid, user2_id uuid)
   155→RETURNS uuid AS $$
   156→DECLARE
   157→  conversation_uuid uuid;
   158→BEGIN
   159→  -- Check if conversation already exists
   160→  SELECT c.id INTO conversation_uuid
   161→  FROM conversations c
   162→  INNER JOIN conversation_participants cp1 ON c.id = cp1.conversation_id
   163→  INNER JOIN conversation_participants cp2 ON c.id = cp2.conversation_id
   164→  WHERE cp1.profile_id = user1_id
   165→    AND cp2.profile_id = user2_id
   166→  LIMIT 1;
   167→
   168→  -- If conversation doesn't exist, create it
   169→  IF conversation_uuid IS NULL THEN
   170→    INSERT INTO conversations DEFAULT VALUES
   171→    RETURNING id INTO conversation_uuid;
   172→
   173→    INSERT INTO conversation_participants (conversation_id, profile_id)
   174→    VALUES (conversation_uuid, user1_id), (conversation_uuid, user2_id);
   175→  END IF;
   176→
   177→  RETURN conversation_uuid;
   178→END;
   179→$$ LANGUAGE plpgsql;

<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 types to verify schema alignment:


     1→export type UserType = "worker" | "contractor";
     2→
     3→export type Availability = "full-time" | "casual" | "weekends" | "flexible";
     4→
     5→export type RateType = "hourly" | "daily";
     6→
     7→export type JobStatus = "draft" | "active" | "filled" | "closed";
     8→
     9→export type MatchStatus =
    10→  | "suggested"
    11→  | "applied"
    12→  | "accepted"
    13→  | "rejected"
    14→  | "hired";
    15→
    16→export type ActionStatus = "pending" | "accepted" | "rejected";
    17→
    18→export type PaymentStatus = "pending" | "charged" | "refunded";
    19→
    20→export type NotificationType = "match" | "hire_request" | "hire_confirmed" | "rating" | "job_posted" | "system";
    21→
    22→export interface Profile {
    23→  id: string;
    24→  type: UserType;
    25→  name: string | null;
    26→  phone: string | null;
    27→  email: string | null;
    28→  avatar_url: string | null;
    29→  /**
    30→   * Location as { lat, lng } object
    31→   * Note: Database stores this as PostgreSQL point type
    32→   * Use postgresPointToJson() when reading from DB
    33→   * Use jsonToPostgresPoint() when writing to DB
    34→   */
    35→  location: { lat: number; lng: number } | null;
    36→  suburb: string | null;
    37→  verified: boolean;
    38→  onboarding_completed: boolean;
    39→  created_at: string;
    40→  updated_at: string;
    41→  ai_generated: boolean;
    42→}
    43→
    44→export interface Company {
    45→  id: string;
    46→  profile_id: string;
    47→  name: string;
    48→  abn: string | null;
    49→  address: string | null;
    50→  industry: string | null;
    51→  size: string | null;
    52→  website: string | null;
    53→  logo_url: string | null;
    54→  ai_research: Record<string, unknown> | null;
    55→  created_at: string;
    56→  updated_at: string;
    57→}
    58→
    59→export interface WorkerProfile {
    60→  id: string;
    61→  profile_id: string;
    62→  trades: string[];
    63→  experience_years: number | null;
    64→  certifications: string[];
    65→  white_card_number: string | null;
    66→  white_card_verified: boolean;
    67→  availability: Availability | null;
    68→  languages: string[];
    69→  bio: string | null;
    70→  voice_note_url: string | null;
    71→  ai_extracted: Record<string, unknown> | null;
    72→  created_at: string;
    73→  updated_at: string;
    74→}
    75→
    76→export interface Job {
    77→  id: string;
    78→  company_id: string;
    79→  title: string;
    80→  description: string | null;
    81→  trade: string;
    82→  /**
    83→   * Location as { lat, lng } object
    84→   * Note: Database stores this as PostgreSQL point type
    85→   * Use postgresPointToJson() when reading from DB
    86→   * Use jsonToPostgresPoint() when writing to DB
    87→   */
    88→  location: { lat: number; lng: number } | null;
    89→  suburb: string | null;
    90→  site_address: string | null;
    91→  rate_min: number | null;
    92→  rate_max: number | null;
    93→  rate_type: RateType;
    94→  start_date: string | null;
    95→  duration: string | null;
    96→  workers_needed: number;
    97→  certifications_required: string[];
    98→  status: JobStatus;
    99→  ai_generated: boolean;
   100→  created_at: string;
   101→  updated_at: string;
   102→}
   103→
   104→export interface Match {
   105→  id: string;
   106→  job_id: string;
   107→  worker_id: string;
   108→  status: MatchStatus;
   109→  match_score: number | null;
   110→  ai_summary: string | null;
   111→  contractor_action: ActionStatus;
   112→  worker_action: ActionStatus;
   113→  hired_at: string | null;
   114→  fee_charged: boolean;
   115→  created_at: string;
   116→  updated_at: string;
   117→}
   118→
   119→export interface Rating {
   120→  id: string;
   121→  match_id: string;
   122→  from_id: string;
   123→  to_id: string;
   124→  score: number;
   125→  comment: string | null;
   126→  created_at: string;
   127→}
   128→
   129→export interface Payment {
   130→  id: string;
   131→  match_id: string;
   132→  company_id: string;
   133→  amount: number;
   134→  status: PaymentStatus;
   135→  stripe_payment_id: string | null;
   136→  created_at: string;
   137→}
   138→
   139→export interface Notification {
   140→  id: string;
   141→  profile_id: string;
   142→  type: NotificationType;
   143→  title: string;
   144→  body: string | null;
   145→  data: Record<string, unknown> | null;
   146→  read: boolean;
   147→  created_at: string;
   148→}
   149→

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


Now let me check for any config.toml file and verify the non-migrated SQL files:


ls -la /home/ccuser/the-50-dollar-app/supabase/*.toml 2>/dev/null || echo "No .toml files found"

Check for config.toml


No .toml files found


     1→-- ============================================
     2→-- FIX CRITICAL RLS POLICIES - Audit Item C5
     3→-- ============================================
     4→-- Fix: matches + payments allow ANY user to insert (WITH CHECK true)
     5→-- Date: 2026-02-07
     6→-- ============================================
     7→
     8→-- ============================================
     9→-- 1. DROP PROBLEMATIC POLICIES
    10→-- ============================================
    11→
    12→-- Drop the overly permissive INSERT policies
    13→DROP POLICY IF EXISTS "System can insert matches" ON matches;
    14→DROP POLICY IF EXISTS "System can insert payments" ON payments;
    15→
    16→-- ============================================
    17→-- 2. CREATE SECURE INSERT POLICIES FOR MATCHES
    18→-- ============================================
    19→
    20→-- Option A: Only system/service role can insert matches
    21→-- This is the most secure option - matches should only be created by:
    22→-- 1. System algorithms suggesting matches
    23→-- 2. Workers applying for jobs (but this should go through an API endpoint)
    24→-- 3. Contractors inviting workers (but this should go through an API endpoint)
    25→
    26→-- For now, we'll create a policy that allows:
    27→-- 1. Workers to apply for jobs (insert matches where they are the worker)
    28→-- 2. Contractors to invite workers to their jobs
    29→-- 3. System/service role (using service key, not JWT)
    30→
    31→CREATE POLICY "Workers can apply for jobs"
    32→  ON matches FOR INSERT
    33→  WITH CHECK (
    34→    -- Worker applying for a job
    35→    (worker_id = auth.uid() AND status = 'applied')
    36→    OR
    37→    -- Contractor inviting a worker to their job
    38→    (
    39→      job_id IN (
    40→        SELECT j.id FROM jobs j
    41→        JOIN companies c ON j.company_id = c.id
    42→        WHERE c.profile_id = auth.uid()
    43→      )
    44→      AND status = 'suggested'
    45→    )
    46→    OR
    47→    -- System/service role (using service key)
    48→    (auth.jwt() ->> 'role' = 'service_role')
    49→  );
    50→
    51→-- ============================================
    52→-- 3. CREATE SECURE INSERT POLICIES FOR PAYMENTS
    53→-- ============================================
    54→
    55→-- Payments should ONLY be created by:
    56→-- 1. System when a match is hired (status changes to 'hired')
    57→-- 2. System/service role (using service key)
    58→
    59→CREATE POLICY "System can insert payments for hired matches"
    60→  ON payments FOR INSERT
    61→  WITH CHECK (
    62→    -- Only system/service role can insert payments
    63→    (auth.jwt() ->> 'role' = 'service_role')
    64→    AND
    65→    -- Payment must be for a hired match
    66→    match_id IN (
    67→      SELECT id FROM matches WHERE status = 'hired'
    68→    )
    69→    AND
    70→    -- Payment company must match the job's company
    71→    company_id IN (
    72→      SELECT j.company_id FROM matches m
    73→      JOIN jobs j ON m.job_id = j.id
    74→      WHERE m.id = match_id
    75→    )
    76→  );
    77→
    78→-- ============================================
    79→-- 4. ADDITIONAL SECURITY MEASURES
    80→-- ============================================
    81→
    82→-- Add a trigger to ensure payments are only created for hired matches
    83→CREATE OR REPLACE FUNCTION validate_payment_match()
    84→RETURNS trigger AS $$
    85→BEGIN
    86→  -- Check if the match is actually hired
    87→  IF NOT EXISTS (
    88→    SELECT 1 FROM matches
    89→    WHERE id = NEW.match_id AND status = 'hired'
    90→  ) THEN
    91→    RAISE EXCEPTION 'Payments can only be created for hired matches';
    92→  END IF;
    93→
    94→  -- Check if the company_id matches the job's company
    95→  IF NOT EXISTS (
    96→    SELECT 1 FROM matches m
    97→    JOIN jobs j ON m.job_id = j.id
    98→    WHERE m.id = NEW.match_id AND j.company_id = NEW.company_id
    99→  ) THEN
   100→    RAISE EXCEPTION 'Payment company must match the job company';
   101→  END IF;
   102→
   103→  RETURN NEW;
   104→END;
   105→$$ LANGUAGE plpgsql;
   106→
   107→DROP TRIGGER IF EXISTS validate_payment_match_trigger ON payments;
   108→CREATE TRIGGER validate_payment_match_trigger
   109→  BEFORE INSERT ON payments
   110→  FOR EACH ROW EXECUTE FUNCTION validate_payment_match();
   111→
   112→-- ============================================
   113→-- 5. UPDATE EXISTING POLICIES FOR CONSISTENCY
   114→-- ============================================
   115→
   116→-- Update matches UPDATE policy to be more restrictive
   117→DROP POLICY IF EXISTS "Involved parties can update matches" ON matches;
   118→
   119→CREATE POLICY "Involved parties can update match status"
   120→  ON matches FOR UPDATE
   121→  USING (
   122→    -- Worker can update their own application status
   123→    (worker_id = auth.uid() AND OLD.status IN ('suggested', 'applied'))
   124→    OR
   125→    -- Contractor can update matches for their jobs
   126→    (
   127→      job_id IN (
   128→        SELECT j.id FROM jobs j
   129→        JOIN companies c ON j.company_id = c.id
   130→        WHERE c.profile_id = auth.uid()
   131→      )
   132→      AND OLD.status IN ('suggested', 'applied')
   133→    )
   134→    OR
   135→    -- System can update any match
   136→    (auth.jwt() ->> 'role' = 'service_role')
   137→  )
   138→  WITH CHECK (
   139→    -- Ensure status transitions are valid
   140→    (
   141→      -- Worker can only accept/reject suggestions
   142→      (worker_id = auth.uid() AND NEW.status IN ('accepted', 'rejected'))
   143→      OR
   144→      -- Contractor can only accept/reject applications
   145→      (
   146→        job_id IN (
   147→          SELECT j.id FROM jobs j
   148→          JOIN companies c ON j.company_id = c.id
   149→          WHERE c.profile_id = auth.uid()
   150→        )
   151→        AND NEW.status IN ('accepted', 'rejected')
   152→      )
   153→      OR
   154→      -- System can set to hired (after payment)
   155→      (NEW.status = 'hired' AND auth.jwt() ->> 'role' = 'service_role')
   156→      OR
   157→      -- Status stays the same (other fields updated)
   158→      (NEW.status = OLD.status)
   159→    )
   160→  );
   161→
   162→-- ============================================
   163→-- 6. VERIFICATION QUERIES
   164→-- ============================================
   165→
   166→-- Check current policies
   167→SELECT
   168→  schemaname,
   169→  tablename,
   170→  policyname,
   171→  permissive,
   172→  roles,
   173→  cmd,
   174→  qual,
   175→  with_check
   176→FROM pg_policies
   177→WHERE tablename IN ('matches', 'payments')
   178→ORDER BY tablename, policyname;
   179→
   180→-- Test data integrity
   181→SELECT
   182→  'Matches with invalid status transitions' as check_name,
   183→  COUNT(*) as issues
   184→FROM matches
   185→WHERE status NOT IN ('suggested', 'applied', 'accepted', 'rejected', 'hired')
   186→UNION ALL
   187→SELECT
   188→  'Payments for non-hired matches' as check_name,
   189→  COUNT(*) as issues
   190→FROM payments p
   191→LEFT JOIN matches m ON p.match_id = m.id
   192→WHERE m.status != 'hired' OR m.id IS NULL;

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


     1→-- ============================================
     2→-- CRITICAL FIX: RLS INSERT POLICIES FOR MATCHES + PAYMENTS
     3→-- Audit Item C5: Fix RLS INSERT policies — matches + payments allow ANY user to insert (WITH CHECK true)
     4→-- ============================================
     5→-- Date: 2026-02-07
     6→-- Priority: CRITICAL
     7→-- ============================================
     8→
     9→-- ============================================
    10→-- PROBLEM ANALYSIS
    11→-- ============================================
    12→-- Current issue: Both matches and payments tables have INSERT policies with WITH CHECK (true)
    13→-- This means ANY authenticated user can insert records into these tables
    14→--
    15→-- Security impact:
    16→-- 1. Users could insert fake matches between any job and worker
    17→-- 2. Users could insert fake payment records
    18→-- 3. Financial fraud, data corruption, system abuse
    19→--
    20→-- Correct behavior:
    21→-- 1. matches INSERT: Only workers (applying) or contractors (inviting) for their own jobs
    22→-- 2. payments INSERT: Only system/service role when match is hired
    23→-- ============================================
    24→
    25→-- ============================================
    26→-- 1. FIX MATCHES TABLE INSERT POLICY
    27→-- ============================================
    28→
    29→-- Drop the dangerous policy
    30→DROP POLICY IF EXISTS "System can insert matches" ON matches;
    31→
    32→-- Create secure INSERT policy for matches
    33→-- Allows:
    34→-- 1. Workers to apply for jobs (worker_id must be their own ID)
    35→-- 2. Contractors to suggest matches for their own jobs
    36→-- 3. Service role (system) for automated matching
    37→CREATE POLICY "Secure matches insert"
    38→  ON matches FOR INSERT
    39→  WITH CHECK (
    40→    -- Worker applying for a job
    41→    (worker_id = auth.uid() AND status = 'applied')
    42→    OR
    43→    -- Contractor suggesting a worker for their job
    44→    (
    45→      job_id IN (
    46→        SELECT j.id FROM jobs j
    47→        JOIN companies c ON j.company_id = c.id
    48→        WHERE c.profile_id = auth.uid()
    49→      )
    50→      AND status = 'suggested'
    51→    )
    52→    OR
    53→    -- System/service role (for automated matching)
    54→    (auth.jwt() ->> 'role' = 'service_role')
    55→  );
    56→
    57→-- ============================================
    58→-- 2. FIX PAYMENTS TABLE INSERT POLICY
    59→-- ============================================
    60→
    61→-- Drop the dangerous policy
    62→DROP POLICY IF EXISTS "System can insert payments" ON payments;
    63→
    64→-- Create secure INSERT policy for payments
    65→-- Only system/service role can insert payments, and only for hired matches
    66→CREATE POLICY "Secure payments insert"
    67→  ON payments FOR INSERT
    68→  WITH CHECK (
    69→    -- Must be service role
    70→    (auth.jwt() ->> 'role' = 'service_role')
    71→    AND
    72→    -- Payment must be for a hired match
    73→    EXISTS (
    74→      SELECT 1 FROM matches
    75→      WHERE id = match_id AND status = 'hired'
    76→    )
    77→    AND
    78→    -- Payment company must own the job
    79→    EXISTS (
    80→      SELECT 1 FROM matches m
    81→      JOIN jobs j ON m.job_id = j.id
    82→      WHERE m.id = match_id AND j.company_id = company_id
    83→    )
    84→  );
    85→
    86→-- ============================================
    87→-- 3. ADD DATA VALIDATION TRIGGER FOR PAYMENTS
    88→-- ============================================
    89→
    90→-- Ensure payments are only created for valid hired matches
    91→CREATE OR REPLACE FUNCTION validate_payment_creation()
    92→RETURNS trigger AS $$
    93→BEGIN
    94→  -- Verify match is hired
    95→  IF NOT EXISTS (
    96→    SELECT 1 FROM matches
    97→    WHERE id = NEW.match_id AND status = 'hired'
    98→  ) THEN
    99→    RAISE EXCEPTION 'Payments can only be created for hired matches (match status must be "hired")';
   100→  END IF;
   101→
   102→  -- Verify company owns the job
   103→  IF NOT EXISTS (
   104→    SELECT 1 FROM matches m
   105→    JOIN jobs j ON m.job_id = j.id
   106→    WHERE m.id = NEW.match_id AND j.company_id = NEW.company_id
   107→  ) THEN
   108→    RAISE EXCEPTION 'Payment company must own the job associated with the match';
   109→  END IF;
   110→
   111→  RETURN NEW;
   112→END;
   113→$$ LANGUAGE plpgsql;
   114→
   115→-- Drop existing trigger if any
   116→DROP TRIGGER IF EXISTS validate_payment_creation_trigger ON payments;
   117→
   118→-- Create trigger
   119→CREATE TRIGGER validate_payment_creation_trigger
   120→  BEFORE INSERT ON payments
   121→  FOR EACH ROW EXECUTE FUNCTION validate_payment_creation();
   122→
   123→-- ============================================
   124→-- 4. UPDATE APPLICATION LOGIC RECOMMENDATIONS
   125→-- ============================================
   126→
   127→-- Note for developers: Application code should now:
   128→-- 1. Use service role key for server-side operations
   129→-- 2. Handle match creation through proper workflows:
   130→--    - Worker apply: Frontend calls API, API validates worker owns worker_id
   131→--    - Contractor invite: Frontend calls API, API validates contractor owns job
   132→--    - System matching: Background job uses service role
   133→-- 3. Payment creation: Only through server-side after Stripe success
   134→
   135→-- ============================================
   136→-- 5. VERIFICATION QUERIES (RUN AFTER MIGRATION)
   137→-- ============================================
   138→
   139→/*
   140→-- Check the new policies
   141→SELECT
   142→  tablename,
   143→  policyname,
   144→  cmd,
   145→  qual,
   146→  with_check
   147→FROM pg_policies
   148→WHERE tablename IN ('matches', 'payments')
   149→ORDER BY tablename, policyname;
   150→
   151→-- Test: Try to insert a match as a regular user (should fail without proper conditions)
   152→-- INSERT INTO matches (job_id, worker_id, status)
   153→-- VALUES ('some-job-id', 'some-worker-id', 'suggested');
   154→
   155→-- Test: Try to insert a payment as a regular user (should always fail)
   156→-- INSERT INTO payments (match_id, company_id, amount)
   157→-- VALUES ('some-match-id', 'some-company-id', 50.00);
   158→*/
   159→
   160→-- ============================================
   161→-- 6. ROLLBACK SCRIPT (IN CASE OF ISSUES)
   162→-- ============================================
   163→/*
   164→-- Rollback to original (insecure) policies:
   165→DROP POLICY IF EXISTS "Secure matches insert" ON matches;
   166→DROP POLICY IF EXISTS "Secure payments insert" ON payments;
   167→DROP TRIGGER IF EXISTS validate_payment_creation_trigger ON payments;
   168→DROP FUNCTION IF EXISTS validate_payment_creation;
   169→
   170→-- Recreate original policies (INSECURE - FOR EMERGENCY ONLY):
   171→CREATE POLICY "System can insert matches"
   172→  ON matches FOR INSERT
   173→  WITH CHECK (true);
   174→
   175→CREATE POLICY "System can insert payments"
   176→  ON payments FOR INSERT
   177→  WITH CHECK (true);
   178→*/
   179→

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


Now let me check for any missing tables or issues with the messaging tables:


grep -r "CREATE TABLE" /home/ccuser/the-50-dollar-app/supabase/ 2>/dev/null | grep -E "(conversations|conversation_participants|messages)" | head -20

Verify messaging tables are created


/home/ccuser/the-50-dollar-app/supabase/add-messaging.sql:CREATE TABLE conversations (
/home/ccuser/the-50-dollar-app/supabase/add-messaging.sql:CREATE TABLE conversation_participants (
/home/ccuser/the-50-dollar-app/supabase/add-messaging.sql:CREATE TABLE messages (


grep -r "CREATE OR REPLACE FUNCTION" /home/ccuser/the-50-dollar-app/supabase/ 2>/dev/null | grep -E "(find_matching_workers|get_or_create_conversation|get_conversation_previews)"

Verify required RPC functions are defined


/home/ccuser/the-50-dollar-app/supabase/schema.sql:CREATE OR REPLACE FUNCTION find_matching_workers(
/home/ccuser/the-50-dollar-app/supabase/migrations/20260208000002_fix_messaging_search_path.sql:CREATE OR REPLACE FUNCTION get_conversation_previews(
/home/ccuser/the-50-dollar-app/supabase/migrations/20260208000002_fix_messaging_search_path.sql:CREATE OR REPLACE FUNCTION get_or_create_conversation(user1_id uuid, user2_id uuid)
/home/ccuser/the-50-dollar-app/supabase/migrations/20250121000001_add_match_scoring_rpc.sql:CREATE OR REPLACE FUNCTION find_matching_workers(
/home/ccuser/the-50-dollar-app/supabase/migrations/20260208000004_add_last_seen_at.sql:CREATE OR REPLACE FUNCTION get_conversation_previews(
/home/ccuser/the-50-dollar-app/supabase/add-messaging.sql:CREATE OR REPLACE FUNCTION get_conversation_previews(
/home/ccuser/the-50-dollar-app/supabase/add-messaging.sql:CREATE OR REPLACE FUNCTION get_or_create_conversation(user1_id uuid, user2_id uuid)


Perfect. Now let me create a comprehensive summary by checking one more detail about the last_seen_at column:


grep -A 5 "last_seen_at" /home/ccuser/the-50-dollar-app/supabase/schema.sql

Check if last_seen_at is in main schema


Let me check if there are any missing pieces in the migrations directory:


ls -1 /home/ccuser/the-50-dollar-app/supabase/migrations/ | sort

List all migration files in order


20250121000001_add_match_scoring_rpc.sql
20260208000001_add_abn_uniqueness_constraint.sql
20260208000002_fix_messaging_search_path.sql
20260208000003_add_payment_indexes.sql
20260208000004_add_last_seen_at.sql
20260208000005_create_avatars_bucket.sql
20260209000001_add_abn_unique_constraint.sql
20260209000002_deep_audit_fixes.sql
20260209000003_add_delete_policies.sql
ABN_MIGRATION_SUMMARY.md


Let me read the ABN_MIGRATION_SUMMARY to understand the migration status:


     1→# ABN Uniqueness Constraint Migration Summary
     2→
     3→## Migration File Created
     4→- **File**: `supabase/migrations/20260208000001_add_abn_uniqueness_constraint.sql`
     5→- **Purpose**: Adds UNIQUE constraint to the `abn` column in the `companies` table
     6→
     7→## Key Steps
     8→
     9→### 1. Pre-Migration Verification
    10→Before running the migration, these queries help identify potential issues:
    11→- Check for duplicate ABN values
    12→- Count total companies with ABN values
    13→
    14→### 2. Duplicate Handling Strategy
    15→If duplicates exist:
    16→- **Option A**: Nullify duplicate ABNs (preserves all companies)
    17→- **Option B**: Manually review and update duplicates
    18→
    19→### 3. Migration Actions
    20→- Adds UNIQUE constraint to `companies.abn` column
    21→- Ensures the performance index exists (`idx_companies_abn`)
    22→
    23→### 4. Post-Migration Verification
    24→- Verify no constraint violations exist
    25→- Test inserting duplicate ABN (should fail)
    26→
    27→## Important Notes
    28→- The migration will FAIL if duplicate ABN values exist
    29→- Always run verification queries before executing the migration
    30→- The migration includes both UP and DOWN scripts for rollback capability
    31→
    32→## Running the Migration
    33→```bash
    34→# Apply migration (from Supabase dashboard or CLI)
    35→supabase db push 20260208000001_add_abn_uniqueness_constraint.sql
    36→
    37→# Rollback if needed
    38→supabase db reset --linked
    39→```

<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 verify the RLS policies comprehensively by checking for any issues:


grep -E "^CREATE POLICY|^DROP POLICY" /home/ccuser/the-50-dollar-app/supabase/schema.sql | wc -l

Count RLS policies in schema


grep -r "^CREATE POLICY\|^DROP POLICY" /home/ccuser/the-50-dollar-app/supabase/migrations/*.sql | wc -l

Count RLS policies in migration files