Files
pwap/web/supabase/migrations/archive/005_create_forum_tables.sql
T
Satoshi Qazi Muhammed e18ba679be db: replace the migration history with a baseline of the live schema
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.
2026-07-31 18:49:59 -07:00

217 lines
7.2 KiB
PL/PgSQL

-- Forum Tables for Pezkuwi Governance
-- Admin Announcements Table
CREATE TABLE IF NOT EXISTS public.admin_announcements (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title TEXT NOT NULL,
content TEXT NOT NULL,
type TEXT NOT NULL DEFAULT 'info', -- 'info', 'warning', 'success', 'critical'
is_active BOOLEAN DEFAULT true,
priority INTEGER DEFAULT 0, -- Higher priority shows first
created_by UUID REFERENCES auth.users(id) ON DELETE SET NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
expires_at TIMESTAMP WITH TIME ZONE,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Forum Categories
CREATE TABLE IF NOT EXISTS public.forum_categories (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL UNIQUE,
description TEXT,
icon TEXT,
color TEXT,
display_order INTEGER DEFAULT 0,
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Forum Discussions
CREATE TABLE IF NOT EXISTS public.forum_discussions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
category_id UUID REFERENCES public.forum_categories(id) ON DELETE SET NULL,
proposal_id TEXT, -- Link to blockchain proposal if applicable
title TEXT NOT NULL,
content TEXT NOT NULL,
author_id UUID REFERENCES auth.users(id) ON DELETE SET NULL,
author_name TEXT NOT NULL,
author_address TEXT, -- Blockchain address
is_pinned BOOLEAN DEFAULT false,
is_locked BOOLEAN DEFAULT false,
views_count INTEGER DEFAULT 0,
replies_count INTEGER DEFAULT 0,
tags TEXT[],
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
last_activity_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Forum Replies
CREATE TABLE IF NOT EXISTS public.forum_replies (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
discussion_id UUID REFERENCES public.forum_discussions(id) ON DELETE CASCADE,
parent_reply_id UUID REFERENCES public.forum_replies(id) ON DELETE CASCADE, -- For nested replies
content TEXT NOT NULL,
author_id UUID REFERENCES auth.users(id) ON DELETE SET NULL,
author_name TEXT NOT NULL,
author_address TEXT,
is_edited BOOLEAN DEFAULT false,
edited_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Discussion Reactions (upvotes/downvotes)
CREATE TABLE IF NOT EXISTS public.forum_reactions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
discussion_id UUID REFERENCES public.forum_discussions(id) ON DELETE CASCADE,
reply_id UUID REFERENCES public.forum_replies(id) ON DELETE CASCADE,
user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE,
reaction_type TEXT NOT NULL, -- 'upvote', 'downvote', 'helpful', etc.
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
UNIQUE(discussion_id, user_id, reaction_type),
UNIQUE(reply_id, user_id, reaction_type)
);
-- Create Indexes
CREATE INDEX IF NOT EXISTS idx_announcements_active ON public.admin_announcements(is_active, priority DESC, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_discussions_category ON public.forum_discussions(category_id);
CREATE INDEX IF NOT EXISTS idx_discussions_proposal ON public.forum_discussions(proposal_id);
CREATE INDEX IF NOT EXISTS idx_discussions_pinned ON public.forum_discussions(is_pinned DESC, last_activity_at DESC);
CREATE INDEX IF NOT EXISTS idx_replies_discussion ON public.forum_replies(discussion_id, created_at);
CREATE INDEX IF NOT EXISTS idx_reactions_discussion ON public.forum_reactions(discussion_id);
CREATE INDEX IF NOT EXISTS idx_reactions_reply ON public.forum_reactions(reply_id);
-- RLS Policies
-- Admin Announcements
ALTER TABLE public.admin_announcements ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Anyone can view active announcements"
ON public.admin_announcements FOR SELECT
USING (is_active = true AND (expires_at IS NULL OR expires_at > NOW()));
CREATE POLICY "Admins can manage announcements"
ON public.admin_announcements FOR ALL
USING (
EXISTS (
SELECT 1 FROM public.admin_roles
WHERE user_id = auth.uid()
)
);
-- Forum Categories
ALTER TABLE public.forum_categories ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Anyone can view active categories"
ON public.forum_categories FOR SELECT
USING (is_active = true);
CREATE POLICY "Admins can manage categories"
ON public.forum_categories FOR ALL
USING (
EXISTS (
SELECT 1 FROM public.admin_roles
WHERE user_id = auth.uid()
)
);
-- Forum Discussions
ALTER TABLE public.forum_discussions ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Anyone can view discussions"
ON public.forum_discussions FOR SELECT
USING (true);
CREATE POLICY "Authenticated users can create discussions"
ON public.forum_discussions FOR INSERT
WITH CHECK (auth.uid() IS NOT NULL);
CREATE POLICY "Authors and admins can update discussions"
ON public.forum_discussions FOR UPDATE
USING (
author_id = auth.uid() OR
EXISTS (
SELECT 1 FROM public.admin_roles
WHERE user_id = auth.uid()
)
);
CREATE POLICY "Authors and admins can delete discussions"
ON public.forum_discussions FOR DELETE
USING (
author_id = auth.uid() OR
EXISTS (
SELECT 1 FROM public.admin_roles
WHERE user_id = auth.uid()
)
);
-- Forum Replies
ALTER TABLE public.forum_replies ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Anyone can view replies"
ON public.forum_replies FOR SELECT
USING (true);
CREATE POLICY "Authenticated users can create replies"
ON public.forum_replies FOR INSERT
WITH CHECK (auth.uid() IS NOT NULL);
CREATE POLICY "Authors and admins can update replies"
ON public.forum_replies FOR UPDATE
USING (
author_id = auth.uid() OR
EXISTS (
SELECT 1 FROM public.admin_roles
WHERE user_id = auth.uid()
)
);
CREATE POLICY "Authors and admins can delete replies"
ON public.forum_replies FOR DELETE
USING (
author_id = auth.uid() OR
EXISTS (
SELECT 1 FROM public.admin_roles
WHERE user_id = auth.uid()
)
);
-- Forum Reactions
ALTER TABLE public.forum_reactions ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Anyone can view reactions"
ON public.forum_reactions FOR SELECT
USING (true);
CREATE POLICY "Authenticated users can manage own reactions"
ON public.forum_reactions FOR ALL
USING (user_id = auth.uid());
-- Insert Default Categories
INSERT INTO public.forum_categories (name, description, icon, color, display_order) VALUES
('Treasury', 'Discussions about treasury proposals and funding', '💰', '#10B981', 1),
('Technical', 'Technical discussions and protocol upgrades', '⚙️', '#3B82F6', 2),
('Governance', 'Governance proposals and voting discussions', '🗳️', '#8B5CF6', 3),
('Community', 'Community initiatives and general discussions', '👥', '#F59E0B', 4),
('Support', 'Help and support for using Pezkuwi', '❓', '#6B7280', 5)
ON CONFLICT (name) DO NOTHING;
-- Function to update last_activity
CREATE OR REPLACE FUNCTION update_discussion_activity()
RETURNS TRIGGER AS $$
BEGIN
UPDATE public.forum_discussions
SET
last_activity_at = NOW(),
replies_count = replies_count + 1
WHERE id = NEW.discussion_id;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER on_reply_created
AFTER INSERT ON public.forum_replies
FOR EACH ROW
EXECUTE FUNCTION update_discussion_activity();