mirror of
https://github.com/pezkuwichain/pwap.git
synced 2026-08-12 20:51:37 +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.
256 lines
8.7 KiB
PL/PgSQL
256 lines
8.7 KiB
PL/PgSQL
-- ========================================
|
|
-- PezkuwiChain - Initial Database Schema
|
|
-- ========================================
|
|
-- Run this in Supabase SQL Editor to set up required tables
|
|
-- Dashboard → SQL Editor → New Query → Paste & Run
|
|
|
|
-- ========================================
|
|
-- 1. PROFILES TABLE
|
|
-- ========================================
|
|
-- Stores user profile information and referral data
|
|
|
|
CREATE TABLE IF NOT EXISTS public.profiles (
|
|
id UUID REFERENCES auth.users(id) ON DELETE CASCADE,
|
|
username TEXT UNIQUE NOT NULL,
|
|
email TEXT,
|
|
full_name TEXT,
|
|
avatar_url TEXT,
|
|
referred_by TEXT,
|
|
referral_code TEXT UNIQUE,
|
|
referral_count INTEGER DEFAULT 0,
|
|
total_referral_rewards DECIMAL(20, 4) DEFAULT 0,
|
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT TIMEZONE('utc'::text, NOW()) NOT NULL,
|
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT TIMEZONE('utc'::text, NOW()) NOT NULL,
|
|
PRIMARY KEY (id)
|
|
);
|
|
|
|
-- Create index for faster lookups
|
|
CREATE INDEX IF NOT EXISTS idx_profiles_username ON public.profiles(username);
|
|
CREATE INDEX IF NOT EXISTS idx_profiles_email ON public.profiles(email);
|
|
CREATE INDEX IF NOT EXISTS idx_profiles_referral_code ON public.profiles(referral_code);
|
|
CREATE INDEX IF NOT EXISTS idx_profiles_referred_by ON public.profiles(referred_by);
|
|
|
|
-- Enable Row Level Security
|
|
ALTER TABLE public.profiles ENABLE ROW LEVEL SECURITY;
|
|
|
|
-- Drop existing policies if they exist
|
|
DROP POLICY IF EXISTS "Users can view their own profile" ON public.profiles;
|
|
DROP POLICY IF EXISTS "Users can update their own profile" ON public.profiles;
|
|
DROP POLICY IF EXISTS "Public profiles are viewable by everyone" ON public.profiles;
|
|
|
|
-- Create policies
|
|
CREATE POLICY "Users can view their own profile"
|
|
ON public.profiles FOR SELECT
|
|
USING (auth.uid() = id);
|
|
|
|
CREATE POLICY "Users can update their own profile"
|
|
ON public.profiles FOR UPDATE
|
|
USING (auth.uid() = id);
|
|
|
|
CREATE POLICY "Public profiles are viewable by everyone"
|
|
ON public.profiles FOR SELECT
|
|
USING (true);
|
|
|
|
-- ========================================
|
|
-- 2. ADMIN ROLES TABLE
|
|
-- ========================================
|
|
-- Stores admin and moderator role assignments
|
|
|
|
CREATE TABLE IF NOT EXISTS public.admin_roles (
|
|
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
|
|
user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE,
|
|
role TEXT NOT NULL CHECK (role IN ('admin', 'super_admin', 'moderator')),
|
|
granted_by UUID REFERENCES auth.users(id),
|
|
granted_at TIMESTAMP WITH TIME ZONE DEFAULT TIMEZONE('utc'::text, NOW()) NOT NULL,
|
|
UNIQUE(user_id)
|
|
);
|
|
|
|
-- Create index for faster lookups
|
|
CREATE INDEX IF NOT EXISTS idx_admin_roles_user_id ON public.admin_roles(user_id);
|
|
|
|
-- Enable Row Level Security
|
|
ALTER TABLE public.admin_roles ENABLE ROW LEVEL SECURITY;
|
|
|
|
-- Drop existing policies if they exist
|
|
DROP POLICY IF EXISTS "Admins can view admin roles" ON public.admin_roles;
|
|
DROP POLICY IF EXISTS "Super admins can manage admin roles" ON public.admin_roles;
|
|
|
|
-- Create policies
|
|
CREATE POLICY "Admins can view admin roles"
|
|
ON public.admin_roles FOR SELECT
|
|
USING (
|
|
EXISTS (
|
|
SELECT 1 FROM public.admin_roles
|
|
WHERE user_id = auth.uid() AND role IN ('admin', 'super_admin')
|
|
)
|
|
);
|
|
|
|
CREATE POLICY "Super admins can manage admin roles"
|
|
ON public.admin_roles FOR ALL
|
|
USING (
|
|
EXISTS (
|
|
SELECT 1 FROM public.admin_roles
|
|
WHERE user_id = auth.uid() AND role = 'super_admin'
|
|
)
|
|
);
|
|
|
|
-- ========================================
|
|
-- 3. WALLETS TABLE
|
|
-- ========================================
|
|
-- Stores user wallet addresses and metadata
|
|
|
|
CREATE TABLE IF NOT EXISTS public.wallets (
|
|
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
|
|
user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE,
|
|
address TEXT NOT NULL,
|
|
network TEXT NOT NULL DEFAULT 'pezkuwichain',
|
|
is_primary BOOLEAN DEFAULT false,
|
|
nickname TEXT,
|
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT TIMEZONE('utc'::text, NOW()) NOT NULL,
|
|
last_used_at TIMESTAMP WITH TIME ZONE,
|
|
UNIQUE(user_id, address, network)
|
|
);
|
|
|
|
-- Create index for faster lookups
|
|
CREATE INDEX IF NOT EXISTS idx_wallets_user_id ON public.wallets(user_id);
|
|
CREATE INDEX IF NOT EXISTS idx_wallets_address ON public.wallets(address);
|
|
|
|
-- Enable Row Level Security
|
|
ALTER TABLE public.wallets ENABLE ROW LEVEL SECURITY;
|
|
|
|
-- Drop existing policies if they exist
|
|
DROP POLICY IF EXISTS "Users can view their own wallets" ON public.wallets;
|
|
DROP POLICY IF EXISTS "Users can manage their own wallets" ON public.wallets;
|
|
|
|
-- Create policies
|
|
CREATE POLICY "Users can view their own wallets"
|
|
ON public.wallets FOR SELECT
|
|
USING (auth.uid() = user_id);
|
|
|
|
CREATE POLICY "Users can manage their own wallets"
|
|
ON public.wallets FOR ALL
|
|
USING (auth.uid() = user_id);
|
|
|
|
-- ========================================
|
|
-- 4. REFERRAL TRACKING TABLE
|
|
-- ========================================
|
|
-- Tracks referral rewards and history
|
|
|
|
CREATE TABLE IF NOT EXISTS public.referral_history (
|
|
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
|
|
referrer_id UUID REFERENCES auth.users(id) ON DELETE CASCADE,
|
|
referred_user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE,
|
|
referral_code TEXT NOT NULL,
|
|
reward_amount DECIMAL(20, 4) DEFAULT 0,
|
|
reward_token TEXT DEFAULT 'PEZ',
|
|
reward_claimed BOOLEAN DEFAULT false,
|
|
claimed_at TIMESTAMP WITH TIME ZONE,
|
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT TIMEZONE('utc'::text, NOW()) NOT NULL,
|
|
UNIQUE(referred_user_id)
|
|
);
|
|
|
|
-- Create index for faster lookups
|
|
CREATE INDEX IF NOT EXISTS idx_referral_history_referrer ON public.referral_history(referrer_id);
|
|
CREATE INDEX IF NOT EXISTS idx_referral_history_referred ON public.referral_history(referred_user_id);
|
|
|
|
-- Enable Row Level Security
|
|
ALTER TABLE public.referral_history ENABLE ROW LEVEL SECURITY;
|
|
|
|
-- Drop existing policies if they exist
|
|
DROP POLICY IF EXISTS "Users can view their referral history" ON public.referral_history;
|
|
|
|
-- Create policies
|
|
CREATE POLICY "Users can view their referral history"
|
|
ON public.referral_history FOR SELECT
|
|
USING (auth.uid() = referrer_id OR auth.uid() = referred_user_id);
|
|
|
|
-- ========================================
|
|
-- 5. FUNCTIONS
|
|
-- ========================================
|
|
|
|
-- Function to auto-generate referral code on profile creation
|
|
CREATE OR REPLACE FUNCTION public.generate_referral_code()
|
|
RETURNS TRIGGER AS $$
|
|
DECLARE
|
|
new_code TEXT;
|
|
code_exists BOOLEAN;
|
|
BEGIN
|
|
-- Generate a random 8-character referral code
|
|
LOOP
|
|
new_code := UPPER(SUBSTRING(MD5(RANDOM()::TEXT) FROM 1 FOR 8));
|
|
|
|
-- Check if code already exists
|
|
SELECT EXISTS(SELECT 1 FROM public.profiles WHERE referral_code = new_code) INTO code_exists;
|
|
|
|
-- Exit loop if code is unique
|
|
EXIT WHEN NOT code_exists;
|
|
END LOOP;
|
|
|
|
NEW.referral_code := new_code;
|
|
RETURN NEW;
|
|
END;
|
|
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
|
|
|
-- Function to update updated_at timestamp
|
|
CREATE OR REPLACE FUNCTION public.update_updated_at_column()
|
|
RETURNS TRIGGER AS $$
|
|
BEGIN
|
|
NEW.updated_at = NOW();
|
|
RETURN NEW;
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
|
|
-- ========================================
|
|
-- 6. TRIGGERS
|
|
-- ========================================
|
|
|
|
-- Drop existing triggers if they exist
|
|
DROP TRIGGER IF EXISTS trigger_generate_referral_code ON public.profiles;
|
|
DROP TRIGGER IF EXISTS trigger_update_profiles_updated_at ON public.profiles;
|
|
|
|
-- Auto-generate referral code when profile is created
|
|
CREATE TRIGGER trigger_generate_referral_code
|
|
BEFORE INSERT ON public.profiles
|
|
FOR EACH ROW
|
|
WHEN (NEW.referral_code IS NULL)
|
|
EXECUTE FUNCTION public.generate_referral_code();
|
|
|
|
-- Auto-update updated_at timestamp
|
|
CREATE TRIGGER trigger_update_profiles_updated_at
|
|
BEFORE UPDATE ON public.profiles
|
|
FOR EACH ROW
|
|
EXECUTE FUNCTION public.update_updated_at_column();
|
|
|
|
-- ========================================
|
|
-- 7. INITIAL DATA (OPTIONAL)
|
|
-- ========================================
|
|
-- Add founder as super admin (if exists)
|
|
|
|
-- Note: Replace 'founder-001' with actual founder user ID after first login
|
|
-- This is commented out by default for security
|
|
|
|
-- INSERT INTO public.admin_roles (user_id, role, granted_by)
|
|
-- VALUES ('founder-001', 'super_admin', 'founder-001')
|
|
-- ON CONFLICT (user_id) DO NOTHING;
|
|
|
|
-- ========================================
|
|
-- SUCCESS MESSAGE
|
|
-- ========================================
|
|
DO $$
|
|
BEGIN
|
|
RAISE NOTICE '========================================';
|
|
RAISE NOTICE 'Database schema created successfully!';
|
|
RAISE NOTICE '========================================';
|
|
RAISE NOTICE 'Tables created:';
|
|
RAISE NOTICE ' - profiles';
|
|
RAISE NOTICE ' - admin_roles';
|
|
RAISE NOTICE ' - wallets';
|
|
RAISE NOTICE ' - referral_history';
|
|
RAISE NOTICE '';
|
|
RAISE NOTICE 'Next steps:';
|
|
RAISE NOTICE '1. Test sign up on the web app';
|
|
RAISE NOTICE '2. Check if profile is created automatically';
|
|
RAISE NOTICE '3. Assign admin role manually if needed';
|
|
RAISE NOTICE '========================================';
|
|
END $$;
|