diff --git a/web/supabase/deploy/apply-migrations.sh b/web/supabase/deploy/apply-migrations.sh index 4eabcb28..40f5fb69 100755 --- a/web/supabase/deploy/apply-migrations.sh +++ b/web/supabase/deploy/apply-migrations.sh @@ -76,3 +76,48 @@ if [[ $pending -eq 0 ]]; then else echo "✔ applied $done migration(s)" fi + +# ── Drift check ─────────────────────────────────────────────────────────────── +# Being recorded as applied is not proof of having been applied. Six migrations +# carry "Run this in Supabase SQL Editor" headers and were pasted in by hand +# before this runner existed; at least one run stopped partway — the tables +# landed, the function bodies did not — and the version was recorded anyway. +# This runner then skipped them forever, so the gap stayed invisible until a +# user hit "Could not find the function ..." in production. +# +# So after applying, compare what the migrations declare against what the +# database actually has. Known, accepted gaps live in known-schema-gaps.txt; +# anything outside that list is new drift and gets surfaced loudly. +echo "▶ drift check: declared functions vs database" + +gaps_file="$MIGRATIONS_DIR/../deploy/known-schema-gaps.txt" +declared="$(grep -rhoiE 'create (or replace )?function (public\.)?[a-z0-9_]+' "$MIGRATIONS_DIR"/*.sql 2>/dev/null \ + | awk '{print tolower($NF)}' | sed 's/^public\.//' | sort -u)" +present="$("${PSQL_Q[@]}" -c \ + "SELECT p.proname FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace WHERE n.nspname = 'public';" \ + | sort -u)" + +missing="$(comm -23 <(echo "$declared") <(echo "$present") || true)" + +if [[ -n "$missing" ]]; then + if [[ -f "$gaps_file" ]]; then + known="$(grep -vE '^\s*(#|$)' "$gaps_file" | tr -d ' \t' | sort -u)" + unexpected="$(comm -23 <(echo "$missing") <(echo "$known") || true)" + else + unexpected="$missing" + fi + + known_count="$(echo "$missing" | grep -c . || true)" + new_count="$(echo "$unexpected" | grep -c . || true)" + + if [[ -n "$unexpected" ]]; then + echo "::warning::schema drift — $new_count function(s) declared in migrations but absent from the database:" + echo "$unexpected" | sed 's/^/ ✗ /' + echo " A migration is recorded as applied but its functions are not there." + echo " Fix with a forward-only repair migration, or add to known-schema-gaps.txt if intentional." + else + echo " ✔ no new drift ($known_count known gap(s), see known-schema-gaps.txt)" + fi +else + echo " ✔ every declared function is present" +fi diff --git a/web/supabase/deploy/known-schema-gaps.txt b/web/supabase/deploy/known-schema-gaps.txt new file mode 100644 index 00000000..88ed303e --- /dev/null +++ b/web/supabase/deploy/known-schema-gaps.txt @@ -0,0 +1,41 @@ +# Functions declared in migrations that are knowingly absent from the database. +# +# Six migrations were recorded as applied but only ran partway: their tables +# exist, their function bodies never ran. Those files carry "Run this in Supabase +# SQL Editor" headers — they were pasted in by hand before apply-migrations.sh +# existed, and a run that stopped midway still got recorded. +# +# Nothing in the app calls any function listed here, and several are trigger +# bodies whose triggers were never created either. Creating them now would switch +# on behaviour that has never been live (P2P notification triggers, fraud +# counters, stats updaters) rather than restore something that broke — a product +# decision, not a repair. They stay listed until someone deliberately enables them. +# +# The three the app does reach were repaired in 20260730150000 and are not here: +# apply_for_tier_upgrade, check_tier_eligibility, update_p2p_reputation +# +# Remove a line when you actually create the function. Anything missing that is +# NOT listed here is new drift and the deploy will say so. + +approve_tier_application +calculate_merchant_stats +calculate_user_risk_score +cancel_expired_offers +check_trade_allowed +decrement_escrow_balance +generate_referral_code +get_payment_method_details +increment_escrow_balance +log_suspicious_activity +notify_on_dispute_opened +notify_on_new_message +notify_on_new_trade +notify_on_payment_sent +notify_on_trade_completed +reset_daily_fraud_counters +reset_weekly_fraud_counters +update_discussion_activity +update_fraud_indicators_on_trade +update_merchant_stats_on_trade +update_rating_stats_trigger +update_user_rating_stats diff --git a/web/supabase/migrations/20260730150000_repair_missing_functions.sql b/web/supabase/migrations/20260730150000_repair_missing_functions.sql new file mode 100644 index 00000000..5a89454d --- /dev/null +++ b/web/supabase/migrations/20260730150000_repair_missing_functions.sql @@ -0,0 +1,280 @@ +-- Repair: recreate functions that their migrations recorded but never created. +-- +-- supabase_migrations.schema_migrations lists 20241211092900 as applied, and its +-- tables (p2p_merchant_tiers, p2p_merchant_stats, p2p_tier_requirements) do +-- exist — but none of its functions do. The same is true of five other +-- migrations: the tables landed, the function bodies did not. Those files carry +-- "Run this in Supabase SQL Editor" headers, so they were pasted in by hand +-- before apply-migrations.sh existed, and a run that stopped partway still got +-- recorded as applied. The CI runner then skipped them forever after. +-- +-- This is forward-only repair rather than a re-run of those files. Re-running +-- them would abort on their bare CREATE POLICY/INDEX/TRIGGER statements, since +-- the tables are already there. +-- +-- 25 declared functions are missing. Only the ones the app actually reaches are +-- restored here: +-- apply_for_tier_upgrade - MerchantApplication.tsx:213 +-- check_tier_eligibility - called by the above +-- update_p2p_reputation - shared/lib/p2p-fiat.ts:803 +-- All are CREATE OR REPLACE, so this file is safe to run repeatedly, and every +-- table they touch was verified present first. +-- +-- Deliberately NOT re-running the source migrations. 20241117054602 is +-- self-consistent, but 016 later redefined its cancel_expired_trades; replaying +-- the whole file would overwrite the newer definition with the older one. Taking +-- only the missing body avoids that. +-- +-- The remaining 22 are left alone: nothing calls them, and several are trigger +-- bodies whose triggers were never created either, so creating them would switch +-- on behaviour that has never run rather than restore something broken. They are +-- listed in deploy/known-schema-gaps.txt. + +CREATE OR REPLACE FUNCTION public.check_tier_eligibility( + p_user_id UUID, + p_target_tier VARCHAR(20) +) RETURNS TABLE( + eligible BOOLEAN, + missing_requirements TEXT[] +) AS $$ +DECLARE + v_reputation RECORD; + v_stats RECORD; + v_requirements RECORD; + v_missing TEXT[] := '{}'; +BEGIN + -- Get requirements + SELECT * INTO v_requirements + FROM public.p2p_tier_requirements + WHERE tier = p_target_tier; + + IF NOT FOUND THEN + eligible := FALSE; + missing_requirements := ARRAY['Invalid tier']; + RETURN NEXT; + RETURN; + END IF; + + -- Get user reputation + SELECT * INTO v_reputation + FROM public.p2p_reputation + WHERE user_id = p_user_id; + + -- Get user stats + SELECT * INTO v_stats + FROM public.p2p_merchant_stats + WHERE user_id = p_user_id; + + -- Check completed trades + IF COALESCE(v_reputation.completed_trades, 0) < v_requirements.min_trades THEN + v_missing := array_append(v_missing, + format('Need %s completed trades (have %s)', + v_requirements.min_trades, + COALESCE(v_reputation.completed_trades, 0))); + END IF; + + -- Check completion rate + IF COALESCE(v_stats.completion_rate_30d, 0) < v_requirements.min_completion_rate THEN + v_missing := array_append(v_missing, + format('Need %s%% completion rate (have %s%%)', + v_requirements.min_completion_rate, + COALESCE(v_stats.completion_rate_30d, 0))); + END IF; + + -- Check 30-day volume + IF COALESCE(v_stats.total_volume_30d, 0) < v_requirements.min_volume_30d THEN + v_missing := array_append(v_missing, + format('Need $%s 30-day volume (have $%s)', + v_requirements.min_volume_30d, + COALESCE(v_stats.total_volume_30d, 0))); + END IF; + + -- Check deposit requirement + IF v_requirements.deposit_required > 0 THEN + v_missing := array_append(v_missing, + format('Deposit of %s %s required', + v_requirements.deposit_required, + v_requirements.deposit_token)); + END IF; + + eligible := array_length(v_missing, 1) IS NULL OR array_length(v_missing, 1) = 0; + missing_requirements := v_missing; + + RETURN NEXT; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +CREATE OR REPLACE FUNCTION public.apply_for_tier_upgrade( + p_user_id UUID, + p_target_tier VARCHAR(20) +) RETURNS TABLE( + success BOOLEAN, + message TEXT +) AS $$ +DECLARE + v_eligibility RECORD; + v_current_tier RECORD; +BEGIN + -- Check current tier + SELECT * INTO v_current_tier + FROM public.p2p_merchant_tiers + WHERE user_id = p_user_id; + + -- Check if already at or above target tier + IF v_current_tier IS NOT NULL THEN + IF v_current_tier.tier = p_target_tier THEN + success := FALSE; + message := 'You are already at this tier'; + RETURN NEXT; + RETURN; + END IF; + + IF v_current_tier.application_status = 'pending' THEN + success := FALSE; + message := 'You already have a pending application'; + RETURN NEXT; + RETURN; + END IF; + END IF; + + -- Check eligibility + SELECT * INTO v_eligibility + FROM public.check_tier_eligibility(p_user_id, p_target_tier); + + IF NOT v_eligibility.eligible THEN + success := FALSE; + message := 'Not eligible: ' || array_to_string(v_eligibility.missing_requirements, ', '); + RETURN NEXT; + RETURN; + END IF; + + -- Create or update application + INSERT INTO public.p2p_merchant_tiers ( + user_id, application_status, applied_at, applied_for_tier + ) VALUES ( + p_user_id, 'pending', NOW(), p_target_tier + ) + ON CONFLICT (user_id) DO UPDATE SET + application_status = 'pending', + applied_at = NOW(), + applied_for_tier = p_target_tier, + updated_at = NOW(); + + success := TRUE; + message := 'Application submitted successfully'; + + RETURN NEXT; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; +GRANT EXECUTE ON FUNCTION public.check_tier_eligibility TO authenticated; +GRANT EXECUTE ON FUNCTION public.apply_for_tier_upgrade TO authenticated; + +CREATE OR REPLACE FUNCTION public.update_p2p_reputation( + p_seller_id UUID, + p_buyer_id UUID, + p_trade_id UUID +) RETURNS void AS $$ +DECLARE + v_trade RECORD; + v_payment_time_minutes INT; + v_confirmation_time_minutes INT; +BEGIN + -- Get trade details + SELECT * INTO v_trade + FROM public.p2p_fiat_trades + WHERE id = p_trade_id; + + IF NOT FOUND THEN + RAISE EXCEPTION 'Trade % not found', p_trade_id; + END IF; + + -- Calculate timing metrics + IF v_trade.buyer_marked_paid_at IS NOT NULL THEN + v_payment_time_minutes := EXTRACT(EPOCH FROM (v_trade.buyer_marked_paid_at - v_trade.created_at)) / 60; + END IF; + + IF v_trade.seller_confirmed_at IS NOT NULL AND v_trade.buyer_marked_paid_at IS NOT NULL THEN + v_confirmation_time_minutes := EXTRACT(EPOCH FROM (v_trade.seller_confirmed_at - v_trade.buyer_marked_paid_at)) / 60; + END IF; + + -- Update seller reputation + INSERT INTO public.p2p_reputation ( + user_id, + total_trades, + completed_trades, + total_as_seller, + reputation_score, + avg_confirmation_time_minutes, + last_trade_at, + first_trade_at + ) VALUES ( + p_seller_id, + 1, + 1, + 1, + 105, -- +5 bonus for first trade + v_confirmation_time_minutes, + NOW(), + NOW() + ) + ON CONFLICT (user_id) DO UPDATE SET + total_trades = p2p_reputation.total_trades + 1, + completed_trades = p2p_reputation.completed_trades + 1, + total_as_seller = p2p_reputation.total_as_seller + 1, + reputation_score = LEAST(p2p_reputation.reputation_score + 5, 1000), + avg_confirmation_time_minutes = CASE + WHEN p2p_reputation.avg_confirmation_time_minutes IS NULL THEN v_confirmation_time_minutes + ELSE (p2p_reputation.avg_confirmation_time_minutes + COALESCE(v_confirmation_time_minutes, 0)) / 2 + END, + last_trade_at = NOW(), + updated_at = NOW(); + + -- Update buyer reputation + INSERT INTO public.p2p_reputation ( + user_id, + total_trades, + completed_trades, + total_as_buyer, + reputation_score, + avg_payment_time_minutes, + last_trade_at, + first_trade_at + ) VALUES ( + p_buyer_id, + 1, + 1, + 1, + 105, + v_payment_time_minutes, + NOW(), + NOW() + ) + ON CONFLICT (user_id) DO UPDATE SET + total_trades = p2p_reputation.total_trades + 1, + completed_trades = p2p_reputation.completed_trades + 1, + total_as_buyer = p2p_reputation.total_as_buyer + 1, + reputation_score = LEAST(p2p_reputation.reputation_score + 5, 1000), + avg_payment_time_minutes = CASE + WHEN p2p_reputation.avg_payment_time_minutes IS NULL THEN v_payment_time_minutes + ELSE (p2p_reputation.avg_payment_time_minutes + COALESCE(v_payment_time_minutes, 0)) / 2 + END, + last_trade_at = NOW(), + updated_at = NOW(); + + -- Update trust levels based on reputation score + UPDATE public.p2p_reputation + SET trust_level = CASE + WHEN reputation_score >= 900 THEN 'verified' + WHEN reputation_score >= 700 THEN 'advanced' + WHEN reputation_score >= 400 THEN 'intermediate' + WHEN reputation_score >= 100 THEN 'basic' + ELSE 'new' + END, + fast_trader = CASE + WHEN avg_payment_time_minutes < 15 AND avg_confirmation_time_minutes < 30 THEN true + ELSE false + END + WHERE user_id IN (p_seller_id, p_buyer_id); +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; +GRANT EXECUTE ON FUNCTION public.update_p2p_reputation TO authenticated;