mirror of
https://github.com/pezkuwichain/pwap.git
synced 2026-08-12 05:50:56 +00:00
e18ba679be
The migrations did not describe this database, and could not be made to.
Three findings, in the order they surfaced:
* 25 functions declared across six migrations — all recorded as applied — did
not exist. Their tables did. Two were reached by the app, so merchant tier
upgrades and post-trade reputation updates had been quietly dead. This only
came to light because a user hit "Could not find the function
public.upsert_user_profile(...)" while toggling a notification setting.
* admin_roles has three conflicting definitions across the set and production
matches none of them. 001 says (id, user_id, role, granted_by, granted_at),
COMBINED says (user_id, role, created_at), production has
(id, user_id, role, permissions, created_at, updated_at).
* Applied to an empty database, five migrations fail. The legacy 0NN filenames
sort before the 14-digit timestamps they depend on — "013" < "20241117054600"
— so 013 runs before the migration creating the table it alters. The set
could never have been replayed from scratch.
So it could not be tested, could not rebuild the database, and did not match what
was running. Widening or patching it would have been dressing up a history that
was already fiction.
The baseline is a pg_dump of the live public schema, which matches production by
construction. Privileges are included deliberately: the REVOKEs on
lock_escrow_internal, release_escrow_internal, refund_escrow_internal and
request_withdraw are the 20260725030000 hardening, and dropping them would hand
fund movement back to anon.
Verified step by step against production before committing:
- applies to an empty database cleanly, after three real obstacles were fixed
(extensions live in the `extensions` schema, supabase_admin membership,
platform-level ALTER DEFAULT PRIVILEGES that cannot apply outside Supabase)
- produces 83 tables / 39 functions / 222 indexes / 360 policies — identical
counts to production
- recorded as applied in supabase_migrations without touching the 37 existing
rows, then dry-run confirmed "up to date — no pending migrations", so the
next deploy will not try to replay it over live tables
- drift check now reports "every declared function is present"; the known-gaps
list drops from 22 entries to zero
The old files move to migrations/archive/ rather than being deleted — they are
the only record of why parts of this look the way they do. Their README says
plainly not to run them, and why.
CI now applies migrations to an empty Postgres on every PR. That check was
impossible while the old set was the starting point; it is the thing that stops
this class of drift from being discovered by a user again.
399 lines
15 KiB
PL/PgSQL
399 lines
15 KiB
PL/PgSQL
-- =====================================================
|
|
-- P2P Fiat Trading System
|
|
-- Production-grade schema with full security & audit
|
|
-- =====================================================
|
|
|
|
-- Enable required extensions
|
|
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
|
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
|
|
|
|
-- =====================================================
|
|
-- PAYMENT METHODS TABLE
|
|
-- =====================================================
|
|
CREATE TABLE IF NOT EXISTS public.payment_methods (
|
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
currency TEXT NOT NULL CHECK (currency IN ('TRY', 'IQD', 'IRR', 'EUR', 'USD')),
|
|
country TEXT NOT NULL,
|
|
method_name TEXT NOT NULL,
|
|
method_type TEXT NOT NULL CHECK (method_type IN ('bank', 'mobile_payment', 'cash', 'crypto_exchange')),
|
|
logo_url TEXT,
|
|
fields JSONB NOT NULL,
|
|
validation_rules JSONB DEFAULT '{}',
|
|
is_active BOOLEAN DEFAULT true,
|
|
display_order INT DEFAULT 0,
|
|
min_trade_amount NUMERIC DEFAULT 0,
|
|
max_trade_amount NUMERIC,
|
|
processing_time_minutes INT DEFAULT 60,
|
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
|
|
|
CONSTRAINT unique_payment_method UNIQUE (currency, method_name)
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_payment_methods_currency_active ON public.payment_methods(currency, is_active);
|
|
CREATE INDEX IF NOT EXISTS idx_payment_methods_type ON public.payment_methods(method_type);
|
|
|
|
-- =====================================================
|
|
-- P2P FIAT OFFERS TABLE
|
|
-- =====================================================
|
|
CREATE TABLE IF NOT EXISTS public.p2p_fiat_offers (
|
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
seller_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
|
seller_wallet TEXT NOT NULL,
|
|
|
|
-- Crypto side
|
|
token TEXT NOT NULL CHECK (token IN ('HEZ', 'PEZ')),
|
|
amount_crypto NUMERIC NOT NULL CHECK (amount_crypto > 0),
|
|
|
|
-- Fiat side
|
|
fiat_currency TEXT NOT NULL CHECK (fiat_currency IN ('TRY', 'IQD', 'IRR', 'EUR', 'USD')),
|
|
fiat_amount NUMERIC NOT NULL CHECK (fiat_amount > 0),
|
|
price_per_unit NUMERIC GENERATED ALWAYS AS (fiat_amount / amount_crypto) STORED,
|
|
|
|
-- Payment details
|
|
payment_method_id UUID NOT NULL REFERENCES public.payment_methods(id),
|
|
payment_details_encrypted TEXT NOT NULL, -- PGP encrypted JSONB
|
|
|
|
-- Terms
|
|
min_order_amount NUMERIC CHECK (min_order_amount > 0 AND min_order_amount <= amount_crypto),
|
|
max_order_amount NUMERIC CHECK (max_order_amount >= min_order_amount AND max_order_amount <= amount_crypto),
|
|
time_limit_minutes INT DEFAULT 30 CHECK (time_limit_minutes BETWEEN 15 AND 120),
|
|
auto_reply_message TEXT,
|
|
|
|
-- Restrictions
|
|
min_buyer_completed_trades INT DEFAULT 0,
|
|
min_buyer_reputation INT DEFAULT 0,
|
|
blocked_users UUID[] DEFAULT '{}',
|
|
|
|
-- Status
|
|
status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'paused', 'locked', 'completed', 'cancelled')),
|
|
remaining_amount NUMERIC NOT NULL CHECK (remaining_amount >= 0 AND remaining_amount <= amount_crypto),
|
|
|
|
-- Escrow tracking
|
|
escrow_tx_hash TEXT,
|
|
escrow_locked_at TIMESTAMPTZ,
|
|
|
|
-- Timestamps
|
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
|
expires_at TIMESTAMPTZ DEFAULT NOW() + INTERVAL '7 days',
|
|
|
|
CONSTRAINT check_order_amounts CHECK (
|
|
(min_order_amount IS NULL AND max_order_amount IS NULL) OR
|
|
(min_order_amount IS NOT NULL AND max_order_amount IS NOT NULL)
|
|
)
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_p2p_offers_seller ON public.p2p_fiat_offers(seller_id);
|
|
CREATE INDEX IF NOT EXISTS idx_p2p_offers_currency ON public.p2p_fiat_offers(fiat_currency, token);
|
|
CREATE INDEX IF NOT EXISTS idx_p2p_offers_status ON public.p2p_fiat_offers(status)WHERE status IN ('open', 'paused');
|
|
CREATE INDEX IF NOT EXISTS idx_p2p_offers_active ON public.p2p_fiat_offers(status, fiat_currency, token)
|
|
WHERE status = 'open' AND remaining_amount > 0;
|
|
|
|
-- =====================================================
|
|
-- P2P FIAT TRADES TABLE
|
|
-- =====================================================
|
|
CREATE TABLE IF NOT EXISTS public.p2p_fiat_trades (
|
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
offer_id UUID NOT NULL REFERENCES public.p2p_fiat_offers(id) ON DELETE CASCADE,
|
|
seller_id UUID NOT NULL REFERENCES auth.users(id),
|
|
buyer_id UUID NOT NULL REFERENCES auth.users(id),
|
|
buyer_wallet TEXT NOT NULL,
|
|
|
|
-- Trade amounts
|
|
crypto_amount NUMERIC NOT NULL CHECK (crypto_amount > 0),
|
|
fiat_amount NUMERIC NOT NULL CHECK (fiat_amount > 0),
|
|
price_per_unit NUMERIC NOT NULL,
|
|
|
|
-- Escrow
|
|
escrow_locked_amount NUMERIC NOT NULL,
|
|
escrow_locked_at TIMESTAMPTZ,
|
|
escrow_release_tx_hash TEXT,
|
|
escrow_released_at TIMESTAMPTZ,
|
|
|
|
-- Payment tracking
|
|
buyer_marked_paid_at TIMESTAMPTZ,
|
|
buyer_payment_proof_url TEXT, -- IPFS hash
|
|
seller_confirmed_at TIMESTAMPTZ,
|
|
|
|
-- Chat messages (encrypted)
|
|
chat_messages JSONB DEFAULT '[]',
|
|
|
|
-- Status
|
|
status TEXT NOT NULL DEFAULT 'pending' CHECK (
|
|
status IN ('pending', 'payment_sent', 'completed', 'cancelled', 'disputed', 'refunded')
|
|
),
|
|
|
|
-- Deadlines
|
|
payment_deadline TIMESTAMPTZ NOT NULL,
|
|
confirmation_deadline TIMESTAMPTZ,
|
|
|
|
-- Cancellation/Dispute
|
|
cancelled_by UUID REFERENCES auth.users(id),
|
|
cancellation_reason TEXT,
|
|
dispute_id UUID,
|
|
|
|
-- Timestamps
|
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
|
completed_at TIMESTAMPTZ,
|
|
|
|
CONSTRAINT different_users CHECK (seller_id != buyer_id)
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_p2p_trades_offer ON public.p2p_fiat_trades(offer_id);
|
|
CREATE INDEX IF NOT EXISTS idx_p2p_trades_seller ON public.p2p_fiat_trades(seller_id);
|
|
CREATE INDEX IF NOT EXISTS idx_p2p_trades_buyer ON public.p2p_fiat_trades(buyer_id);
|
|
CREATE INDEX IF NOT EXISTS idx_p2p_trades_status ON public.p2p_fiat_trades(status);
|
|
CREATE INDEX IF NOT EXISTS idx_p2p_trades_deadlines ON public.p2p_fiat_trades(payment_deadline, confirmation_deadline)
|
|
WHERE status IN ('pending', 'payment_sent');
|
|
|
|
-- =====================================================
|
|
-- P2P DISPUTES TABLE
|
|
-- =====================================================
|
|
CREATE TABLE IF NOT EXISTS public.p2p_fiat_disputes (
|
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
trade_id UUID NOT NULL REFERENCES public.p2p_fiat_trades(id) ON DELETE CASCADE,
|
|
opened_by UUID NOT NULL REFERENCES auth.users(id),
|
|
|
|
-- Dispute details
|
|
reason TEXT NOT NULL CHECK (LENGTH(reason) >= 20),
|
|
category TEXT NOT NULL CHECK (
|
|
category IN ('payment_not_received', 'wrong_amount', 'fake_payment_proof', 'other')
|
|
),
|
|
evidence_urls TEXT[] DEFAULT '{}', -- IPFS hashes
|
|
additional_info JSONB DEFAULT '{}',
|
|
|
|
-- Moderator assignment
|
|
assigned_moderator_id UUID REFERENCES auth.users(id),
|
|
assigned_at TIMESTAMPTZ,
|
|
|
|
-- Resolution
|
|
decision TEXT CHECK (decision IN ('release_to_buyer', 'refund_to_seller', 'split', 'escalate')),
|
|
decision_reasoning TEXT,
|
|
resolved_at TIMESTAMPTZ,
|
|
|
|
-- Status
|
|
status TEXT NOT NULL DEFAULT 'open' CHECK (
|
|
status IN ('open', 'under_review', 'resolved', 'escalated', 'closed')
|
|
),
|
|
|
|
-- Timestamps
|
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
|
|
|
CONSTRAINT one_dispute_per_trade UNIQUE (trade_id)
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_disputes_trade ON public.p2p_fiat_disputes(trade_id);
|
|
CREATE INDEX IF NOT EXISTS idx_disputes_status ON public.p2p_fiat_disputes(status)WHERE status IN ('open', 'under_review');
|
|
CREATE INDEX IF NOT EXISTS idx_disputes_moderator ON public.p2p_fiat_disputes(assigned_moderator_id) WHERE status = 'under_review';
|
|
|
|
-- =====================================================
|
|
-- P2P REPUTATION TABLE
|
|
-- =====================================================
|
|
CREATE TABLE IF NOT EXISTS public.p2p_reputation (
|
|
user_id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
|
|
|
|
-- Trade statistics
|
|
total_trades INT DEFAULT 0 CHECK (total_trades >= 0),
|
|
completed_trades INT DEFAULT 0 CHECK (completed_trades >= 0 AND completed_trades <= total_trades),
|
|
cancelled_trades INT DEFAULT 0 CHECK (cancelled_trades >= 0),
|
|
disputed_trades INT DEFAULT 0 CHECK (disputed_trades >= 0),
|
|
|
|
-- Role statistics
|
|
total_as_seller INT DEFAULT 0 CHECK (total_as_seller >= 0),
|
|
total_as_buyer INT DEFAULT 0 CHECK (total_as_buyer >= 0),
|
|
|
|
-- Volume
|
|
total_volume_usd NUMERIC DEFAULT 0 CHECK (total_volume_usd >= 0),
|
|
|
|
-- Timing metrics
|
|
avg_payment_time_minutes INT,
|
|
avg_confirmation_time_minutes INT,
|
|
|
|
-- Reputation score (0-1000)
|
|
reputation_score INT DEFAULT 100 CHECK (reputation_score BETWEEN 0 AND 1000),
|
|
trust_level TEXT DEFAULT 'new' CHECK (
|
|
trust_level IN ('new', 'basic', 'intermediate', 'advanced', 'verified')
|
|
),
|
|
|
|
-- Badges
|
|
verified_merchant BOOLEAN DEFAULT false,
|
|
fast_trader BOOLEAN DEFAULT false,
|
|
|
|
-- Restrictions
|
|
is_restricted BOOLEAN DEFAULT false,
|
|
restriction_reason TEXT,
|
|
restricted_until TIMESTAMPTZ,
|
|
|
|
-- Timestamps
|
|
first_trade_at TIMESTAMPTZ,
|
|
last_trade_at TIMESTAMPTZ,
|
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_reputation_score ON public.p2p_reputation(reputation_score DESC);
|
|
CREATE INDEX IF NOT EXISTS idx_reputation_verified ON public.p2p_reputation(verified_merchant) WHERE verified_merchant = true;
|
|
|
|
-- =====================================================
|
|
-- PLATFORM ESCROW TRACKING
|
|
-- =====================================================
|
|
CREATE TABLE IF NOT EXISTS public.platform_escrow_balance (
|
|
token TEXT PRIMARY KEY CHECK (token IN ('HEZ', 'PEZ')),
|
|
total_locked NUMERIC DEFAULT 0 CHECK (total_locked >= 0),
|
|
hot_wallet_address TEXT NOT NULL,
|
|
last_audit_at TIMESTAMPTZ,
|
|
last_audit_blockchain_balance NUMERIC,
|
|
discrepancy NUMERIC GENERATED ALWAYS AS (last_audit_blockchain_balance - total_locked) STORED,
|
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
|
);
|
|
|
|
-- =====================================================
|
|
-- AUDIT LOG
|
|
-- =====================================================
|
|
CREATE TABLE IF NOT EXISTS public.p2p_audit_log (
|
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
user_id UUID REFERENCES auth.users(id),
|
|
action TEXT NOT NULL,
|
|
entity_type TEXT NOT NULL CHECK (entity_type IN ('offer', 'trade', 'dispute')),
|
|
entity_id UUID NOT NULL,
|
|
details JSONB DEFAULT '{}',
|
|
ip_address INET,
|
|
user_agent TEXT,
|
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_audit_log_user ON public.p2p_audit_log(user_id, created_at DESC);
|
|
CREATE INDEX IF NOT EXISTS idx_audit_log_entity ON public.p2p_audit_log(entity_type, entity_id);
|
|
CREATE INDEX IF NOT EXISTS idx_audit_log_created ON public.p2p_audit_log(created_at DESC);
|
|
|
|
-- =====================================================
|
|
-- TRIGGERS FOR UPDATED_AT
|
|
-- =====================================================
|
|
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
|
RETURNS TRIGGER AS $$
|
|
BEGIN
|
|
NEW.updated_at = NOW();
|
|
RETURN NEW;
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
|
|
DROP TRIGGER IF EXISTS update_payment_methods_updated_at ON public.payment_methods;
|
|
CREATE TRIGGER update_payment_methods_updated_at BEFORE UPDATE ON public.payment_methods
|
|
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
|
|
|
DROP TRIGGER IF EXISTS update_p2p_offers_updated_at ON public.p2p_fiat_offers;
|
|
CREATE TRIGGER update_p2p_offers_updated_at BEFORE UPDATE ON public.p2p_fiat_offers
|
|
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
|
|
|
DROP TRIGGER IF EXISTS update_p2p_trades_updated_at ON public.p2p_fiat_trades;
|
|
CREATE TRIGGER update_p2p_trades_updated_at BEFORE UPDATE ON public.p2p_fiat_trades
|
|
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
|
|
|
DROP TRIGGER IF EXISTS update_p2p_disputes_updated_at ON public.p2p_fiat_disputes;
|
|
CREATE TRIGGER update_p2p_disputes_updated_at BEFORE UPDATE ON public.p2p_fiat_disputes
|
|
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
|
|
|
-- =====================================================
|
|
-- RLS POLICIES
|
|
-- =====================================================
|
|
|
|
-- Payment Methods: Public read
|
|
ALTER TABLE public.payment_methods ENABLE ROW LEVEL SECURITY;
|
|
CREATE POLICY "payment_methods_public_read" ON public.payment_methods
|
|
FOR SELECT USING (is_active = true);
|
|
|
|
CREATE POLICY "payment_methods_admin_all" ON public.payment_methods
|
|
FOR ALL USING (
|
|
EXISTS (SELECT 1 FROM public.admin_roles WHERE user_id = auth.uid())
|
|
);
|
|
|
|
-- P2P Offers: Public read active, sellers manage own
|
|
ALTER TABLE public.p2p_fiat_offers ENABLE ROW LEVEL SECURITY;
|
|
|
|
CREATE POLICY "offers_public_read_active" ON public.p2p_fiat_offers
|
|
FOR SELECT USING (
|
|
status IN ('open', 'paused') AND
|
|
remaining_amount > 0 AND
|
|
expires_at > NOW()
|
|
);
|
|
|
|
CREATE POLICY "offers_seller_read_own" ON public.p2p_fiat_offers
|
|
FOR SELECT USING (seller_id = auth.uid());
|
|
|
|
CREATE POLICY "offers_seller_insert" ON public.p2p_fiat_offers
|
|
FOR INSERT WITH CHECK (seller_id = auth.uid());
|
|
|
|
CREATE POLICY "offers_seller_update_own" ON public.p2p_fiat_offers
|
|
FOR UPDATE USING (seller_id = auth.uid());
|
|
|
|
CREATE POLICY "offers_seller_delete_own" ON public.p2p_fiat_offers
|
|
FOR DELETE USING (seller_id = auth.uid() AND status IN ('open', 'paused'));
|
|
|
|
-- P2P Trades: Parties can view/update own trades
|
|
ALTER TABLE public.p2p_fiat_trades ENABLE ROW LEVEL SECURITY;
|
|
|
|
CREATE POLICY "trades_parties_read" ON public.p2p_fiat_trades
|
|
FOR SELECT USING (seller_id = auth.uid() OR buyer_id = auth.uid());
|
|
|
|
CREATE POLICY "trades_buyer_insert" ON public.p2p_fiat_trades
|
|
FOR INSERT WITH CHECK (buyer_id = auth.uid());
|
|
|
|
CREATE POLICY "trades_parties_update" ON public.p2p_fiat_trades
|
|
FOR UPDATE USING (seller_id = auth.uid() OR buyer_id = auth.uid());
|
|
|
|
-- Disputes: Parties and moderators
|
|
ALTER TABLE public.p2p_fiat_disputes ENABLE ROW LEVEL SECURITY;
|
|
|
|
CREATE POLICY "disputes_parties_read" ON public.p2p_fiat_disputes
|
|
FOR SELECT USING (
|
|
opened_by = auth.uid() OR
|
|
EXISTS (
|
|
SELECT 1 FROM public.p2p_fiat_trades t
|
|
WHERE t.id = trade_id AND (t.seller_id = auth.uid() OR t.buyer_id = auth.uid())
|
|
)
|
|
);
|
|
|
|
CREATE POLICY "disputes_moderators_read" ON public.p2p_fiat_disputes
|
|
FOR SELECT USING (
|
|
EXISTS (
|
|
SELECT 1 FROM public.admin_roles
|
|
WHERE user_id = auth.uid() AND role IN ('moderator', 'admin')
|
|
)
|
|
);
|
|
|
|
CREATE POLICY "disputes_parties_insert" ON public.p2p_fiat_disputes
|
|
FOR INSERT WITH CHECK (
|
|
opened_by = auth.uid() AND
|
|
EXISTS (
|
|
SELECT 1 FROM public.p2p_fiat_trades t
|
|
WHERE t.id = trade_id AND (t.seller_id = auth.uid() OR t.buyer_id = auth.uid())
|
|
)
|
|
);
|
|
|
|
-- Reputation: Public read, system updates
|
|
ALTER TABLE public.p2p_reputation ENABLE ROW LEVEL SECURITY;
|
|
|
|
CREATE POLICY "reputation_public_read" ON public.p2p_reputation
|
|
FOR SELECT USING (true);
|
|
|
|
-- Escrow: Admin only
|
|
ALTER TABLE public.platform_escrow_balance ENABLE ROW LEVEL SECURITY;
|
|
|
|
CREATE POLICY "escrow_admin_only" ON public.platform_escrow_balance
|
|
FOR ALL USING (
|
|
EXISTS (SELECT 1 FROM public.admin_roles WHERE user_id = auth.uid())
|
|
);
|
|
|
|
-- Audit log: Own + admins
|
|
ALTER TABLE public.p2p_audit_log ENABLE ROW LEVEL SECURITY;
|
|
|
|
CREATE POLICY "audit_user_read_own" ON public.p2p_audit_log
|
|
FOR SELECT USING (user_id = auth.uid());
|
|
|
|
CREATE POLICY "audit_admin_read_all" ON public.p2p_audit_log
|
|
FOR SELECT USING (
|
|
EXISTS (SELECT 1 FROM public.admin_roles WHERE user_id = auth.uid())
|
|
);
|