BrianIsaac's picture
feat: migrate portfolio auto-save from cache to persistent database storage
dbe3108
-- Portfolio Intelligence Platform Database Schema
-- For Supabase PostgreSQL with Auth Integration
-- Enable UUID extension (if not already enabled)
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- Helper function to convert strings to UUIDs (for demo portfolio IDs)
CREATE OR REPLACE FUNCTION string_to_portfolio_uuid(input_string TEXT)
RETURNS UUID
LANGUAGE plpgsql
IMMUTABLE
SECURITY INVOKER
SET search_path = ''
AS $$
BEGIN
-- Use URL namespace UUID constant (6ba7b811-9dad-11d1-80b4-00c04fd430c8)
-- uuid_ns_url() is a constant in PostgreSQL, not a function
RETURN public.uuid_generate_v5('6ba7b811-9dad-11d1-80b4-00c04fd430c8'::uuid, input_string);
END;
$$;
-- Grant execute to authenticated and anon roles
GRANT EXECUTE ON FUNCTION string_to_portfolio_uuid TO authenticated, anon;
-- Users table (linked to Supabase Auth - CRITICAL FIX)
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
email VARCHAR(255) UNIQUE NOT NULL,
username VARCHAR(100),
is_active BOOLEAN DEFAULT true,
is_demo BOOLEAN DEFAULT false,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_users_id ON users(id);
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
-- Portfolios table
CREATE TABLE IF NOT EXISTS portfolios (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name VARCHAR(200) NOT NULL,
description TEXT,
risk_tolerance VARCHAR(20) DEFAULT 'moderate',
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_portfolios_user_id ON portfolios(user_id);
CREATE INDEX IF NOT EXISTS idx_portfolios_created_at ON portfolios(created_at DESC);
-- Portfolio inputs table (for auto-save history - keeps last 3 per user)
CREATE TABLE IF NOT EXISTS portfolio_inputs (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
description TEXT NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_portfolio_inputs_user_created ON portfolio_inputs(user_id, created_at DESC);
-- Trigger function to limit portfolio inputs to 3 per user
CREATE OR REPLACE FUNCTION limit_portfolio_inputs_per_user()
RETURNS TRIGGER
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = ''
AS $$
BEGIN
-- Keep only the 3 most recent entries per user
DELETE FROM public.portfolio_inputs
WHERE id IN (
SELECT id
FROM (
SELECT id,
ROW_NUMBER() OVER (
PARTITION BY user_id
ORDER BY created_at DESC
) AS row_num
FROM public.portfolio_inputs
WHERE user_id = NEW.user_id
) ranked
WHERE row_num > 3
);
RETURN NULL;
END;
$$;
-- Create trigger for automatic cleanup
DROP TRIGGER IF EXISTS trigger_limit_portfolio_inputs ON portfolio_inputs;
CREATE TRIGGER trigger_limit_portfolio_inputs
AFTER INSERT ON portfolio_inputs
FOR EACH ROW EXECUTE FUNCTION limit_portfolio_inputs_per_user();
-- Portfolio holdings table
CREATE TABLE IF NOT EXISTS portfolio_holdings (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
portfolio_id UUID NOT NULL REFERENCES portfolios(id) ON DELETE CASCADE,
ticker VARCHAR(20) NOT NULL,
quantity NUMERIC(20, 8) NOT NULL,
cost_basis NUMERIC(20, 2),
asset_type VARCHAR(20) DEFAULT 'stock',
added_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_holdings_portfolio_id ON portfolio_holdings(portfolio_id);
CREATE INDEX IF NOT EXISTS idx_holdings_ticker ON portfolio_holdings(ticker);
-- Portfolio analyses table
CREATE TABLE IF NOT EXISTS portfolio_analyses (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
portfolio_id UUID NOT NULL REFERENCES portfolios(id) ON DELETE CASCADE,
analysis_date TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
-- Analysis results (JSONB for flexibility)
holdings_snapshot JSONB NOT NULL,
market_data JSONB,
risk_metrics JSONB,
optimisation_results JSONB,
ai_synthesis TEXT,
recommendations JSONB,
-- Metadata
reasoning_steps JSONB,
mcp_calls JSONB,
sentiment_data JSONB,
execution_time_ms INTEGER,
model_version VARCHAR(50),
-- Computed fields
total_value NUMERIC(20, 2),
health_score INTEGER CHECK (health_score >= 0 AND health_score <= 10),
risk_level VARCHAR(20),
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_analyses_portfolio_id ON portfolio_analyses(portfolio_id);
CREATE INDEX IF NOT EXISTS idx_analyses_date ON portfolio_analyses(analysis_date DESC);
CREATE INDEX IF NOT EXISTS idx_analyses_health_score ON portfolio_analyses(health_score);
CREATE INDEX IF NOT EXISTS idx_analyses_sentiment_data ON portfolio_analyses USING GIN (sentiment_data);
-- Function to automatically create user profile on signup (CRITICAL FOR AUTH)
-- Uses SECURITY DEFINER with empty search_path to bypass RLS on INSERT
-- Fully qualifies table name (public.users) to work with empty search_path
CREATE OR REPLACE FUNCTION public.handle_new_user()
RETURNS TRIGGER
LANGUAGE plpgsql
SECURITY DEFINER SET search_path = ''
AS $$
BEGIN
INSERT INTO public.users (id, email, username, is_active, is_demo)
VALUES (
NEW.id,
NEW.email,
COALESCE(
NEW.raw_user_meta_data ->> 'username',
split_part(NEW.email, '@', 1)
),
true,
false
);
RETURN NEW;
EXCEPTION
WHEN OTHERS THEN
-- Log error but don't block signup
RAISE WARNING 'Failed to create user profile: %', SQLERRM;
RETURN NEW;
END;
$$;
-- Create trigger for automatic profile creation
DROP TRIGGER IF EXISTS on_auth_user_created ON auth.users;
CREATE TRIGGER on_auth_user_created
AFTER INSERT ON auth.users
FOR EACH ROW EXECUTE FUNCTION public.handle_new_user();
-- Function to update updated_at timestamp
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = ''
AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$;
-- Triggers for updated_at
DROP TRIGGER IF EXISTS update_users_updated_at ON users;
CREATE TRIGGER update_users_updated_at BEFORE UPDATE ON users
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
DROP TRIGGER IF EXISTS update_portfolios_updated_at ON portfolios;
CREATE TRIGGER update_portfolios_updated_at BEFORE UPDATE ON portfolios
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
-- Grant permissions (Supabase uses RLS - Row Level Security)
-- These are basic permissions; adjust based on your auth setup
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
ALTER TABLE portfolios ENABLE ROW LEVEL SECURITY;
ALTER TABLE portfolio_inputs ENABLE ROW LEVEL SECURITY;
ALTER TABLE portfolio_holdings ENABLE ROW LEVEL SECURITY;
ALTER TABLE portfolio_analyses ENABLE ROW LEVEL SECURITY;
-- RLS policies with performance optimizations (wrapped auth.uid() = 20x faster)
-- Users table policies
DROP POLICY IF EXISTS users_read_own ON users;
CREATE POLICY users_read_own ON users
FOR SELECT
TO authenticated
USING ((SELECT auth.uid()) = id);
DROP POLICY IF EXISTS users_update_own ON users;
CREATE POLICY users_update_own ON users
FOR UPDATE
TO authenticated
USING ((SELECT auth.uid()) = id)
WITH CHECK ((SELECT auth.uid()) = id);
DROP POLICY IF EXISTS users_insert_own ON users;
CREATE POLICY users_insert_own ON users
FOR INSERT
TO authenticated
WITH CHECK ((SELECT auth.uid()) = id);
-- Portfolios table policies
DROP POLICY IF EXISTS portfolios_read_own ON portfolios;
CREATE POLICY portfolios_read_own ON portfolios
FOR SELECT
TO authenticated
USING ((SELECT auth.uid()) = user_id);
DROP POLICY IF EXISTS portfolios_insert_own ON portfolios;
CREATE POLICY portfolios_insert_own ON portfolios
FOR INSERT
TO authenticated
WITH CHECK ((SELECT auth.uid()) = user_id);
DROP POLICY IF EXISTS portfolios_update_own ON portfolios;
CREATE POLICY portfolios_update_own ON portfolios
FOR UPDATE
TO authenticated
USING ((SELECT auth.uid()) = user_id)
WITH CHECK ((SELECT auth.uid()) = user_id);
DROP POLICY IF EXISTS portfolios_delete_own ON portfolios;
CREATE POLICY portfolios_delete_own ON portfolios
FOR DELETE
TO authenticated
USING ((SELECT auth.uid()) = user_id);
-- Portfolio inputs table policies
DROP POLICY IF EXISTS portfolio_inputs_read_own ON portfolio_inputs;
CREATE POLICY portfolio_inputs_read_own ON portfolio_inputs
FOR SELECT
TO authenticated
USING ((SELECT auth.uid()) = user_id);
DROP POLICY IF EXISTS portfolio_inputs_insert_own ON portfolio_inputs;
CREATE POLICY portfolio_inputs_insert_own ON portfolio_inputs
FOR INSERT
TO authenticated
WITH CHECK ((SELECT auth.uid()) = user_id);
DROP POLICY IF EXISTS portfolio_inputs_delete_own ON portfolio_inputs;
CREATE POLICY portfolio_inputs_delete_own ON portfolio_inputs
FOR DELETE
TO authenticated
USING ((SELECT auth.uid()) = user_id);
-- Holdings policies (optimized subquery pattern)
DROP POLICY IF EXISTS holdings_access_own ON portfolio_holdings;
CREATE POLICY holdings_access_own ON portfolio_holdings
FOR ALL
TO authenticated
USING (
portfolio_id IN (
SELECT id FROM portfolios
WHERE user_id = (SELECT auth.uid())
)
)
WITH CHECK (
portfolio_id IN (
SELECT id FROM portfolios
WHERE user_id = (SELECT auth.uid())
)
);
-- Analyses policies
DROP POLICY IF EXISTS analyses_access_own ON portfolio_analyses;
CREATE POLICY analyses_access_own ON portfolio_analyses
FOR ALL
TO authenticated
USING (
portfolio_id IN (
SELECT id FROM portfolios
WHERE user_id = (SELECT auth.uid())
)
)
WITH CHECK (
portfolio_id IN (
SELECT id FROM portfolios
WHERE user_id = (SELECT auth.uid())
)
);
-- Grant permissions
GRANT USAGE ON SCHEMA public TO anon, authenticated;
GRANT ALL ON ALL TABLES IN SCHEMA public TO anon, authenticated;
GRANT ALL ON ALL SEQUENCES IN SCHEMA public TO anon, authenticated;
GRANT ALL ON ALL FUNCTIONS IN SCHEMA public TO anon, authenticated;