-- Monaco USA Portal - Post-Deployment Database Fixes -- This script is IDEMPOTENT - safe to run multiple times. -- Run after `docker compose up` once all containers are healthy. -- -- Handles: -- 1. Storage RLS policies (storage-api creates tables AFTER db init) -- 2. Service role access grants -- 3. Incremental migrations for existing databases -- 4. Notifications table (if missing) -- ============================================================================ -- ============================================ -- 1. STORAGE POLICIES -- storage-api creates storage.objects and storage.buckets with RLS enabled -- but no policies. We need to add service_role policies so the portal -- can upload/delete files via supabaseAdmin. -- ============================================ DO $$ BEGIN -- Ensure service_role has BYPASSRLS IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'service_role' AND NOT rolbypassrls) THEN ALTER ROLE service_role BYPASSRLS; END IF; EXCEPTION WHEN insufficient_privilege THEN RAISE NOTICE 'Could not grant BYPASSRLS to service_role - will rely on explicit policies'; END $$; -- storage.objects policies DO $$ BEGIN IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema = 'storage' AND table_name = 'objects') THEN -- Drop and recreate to ensure clean state DROP POLICY IF EXISTS "service_role_all_select" ON storage.objects; DROP POLICY IF EXISTS "service_role_all_insert" ON storage.objects; DROP POLICY IF EXISTS "service_role_all_update" ON storage.objects; DROP POLICY IF EXISTS "service_role_all_delete" ON storage.objects; CREATE POLICY "service_role_all_select" ON storage.objects FOR SELECT TO service_role USING (true); CREATE POLICY "service_role_all_insert" ON storage.objects FOR INSERT TO service_role WITH CHECK (true); CREATE POLICY "service_role_all_update" ON storage.objects FOR UPDATE TO service_role USING (true); CREATE POLICY "service_role_all_delete" ON storage.objects FOR DELETE TO service_role USING (true); -- Public read access for avatars and event images DROP POLICY IF EXISTS "public_read_avatars" ON storage.objects; CREATE POLICY "public_read_avatars" ON storage.objects FOR SELECT USING (bucket_id IN ('avatars', 'event-images')); -- Authenticated users can read documents DROP POLICY IF EXISTS "authenticated_read_documents" ON storage.objects; CREATE POLICY "authenticated_read_documents" ON storage.objects FOR SELECT TO authenticated USING (bucket_id = 'documents'); GRANT ALL ON storage.objects TO service_role; RAISE NOTICE 'storage.objects policies applied'; ELSE RAISE NOTICE 'storage.objects table not found - storage-api may not have started yet'; END IF; END $$; -- storage.buckets policies DO $$ BEGIN IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema = 'storage' AND table_name = 'buckets') THEN DROP POLICY IF EXISTS "service_role_all_buckets_select" ON storage.buckets; DROP POLICY IF EXISTS "service_role_all_buckets_insert" ON storage.buckets; DROP POLICY IF EXISTS "service_role_all_buckets_update" ON storage.buckets; DROP POLICY IF EXISTS "service_role_all_buckets_delete" ON storage.buckets; CREATE POLICY "service_role_all_buckets_select" ON storage.buckets FOR SELECT TO service_role USING (true); CREATE POLICY "service_role_all_buckets_insert" ON storage.buckets FOR INSERT TO service_role WITH CHECK (true); CREATE POLICY "service_role_all_buckets_update" ON storage.buckets FOR UPDATE TO service_role USING (true); CREATE POLICY "service_role_all_buckets_delete" ON storage.buckets FOR DELETE TO service_role USING (true); -- Allow authenticated users to read bucket info (needed for uploads) DROP POLICY IF EXISTS "authenticated_read_buckets" ON storage.buckets; CREATE POLICY "authenticated_read_buckets" ON storage.buckets FOR SELECT TO authenticated USING (true); GRANT ALL ON storage.buckets TO service_role; RAISE NOTICE 'storage.buckets policies applied'; ELSE RAISE NOTICE 'storage.buckets table not found - storage-api may not have started yet'; END IF; END $$; -- Ensure schema and general grants GRANT USAGE ON SCHEMA storage TO service_role; GRANT USAGE ON SCHEMA storage TO authenticated; -- ============================================ -- 2. STORAGE BUCKETS -- Ensure required buckets exist -- ============================================ DO $$ BEGIN IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema = 'storage' AND table_name = 'buckets') THEN INSERT INTO storage.buckets (id, name, public, file_size_limit, allowed_mime_types) VALUES ( 'avatars', 'avatars', true, 5242880, ARRAY['image/jpeg', 'image/png', 'image/webp', 'image/gif'] ) ON CONFLICT (id) DO UPDATE SET public = true, file_size_limit = EXCLUDED.file_size_limit, allowed_mime_types = EXCLUDED.allowed_mime_types; INSERT INTO storage.buckets (id, name, public, file_size_limit, allowed_mime_types) VALUES ( 'documents', 'documents', true, 52428800, ARRAY['application/pdf', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'application/vnd.ms-powerpoint', 'application/vnd.openxmlformats-officedocument.presentationml.presentation', 'text/plain', 'text/csv', 'application/json', 'image/jpeg', 'image/png', 'image/webp', 'image/gif'] ) ON CONFLICT (id) DO UPDATE SET public = true, file_size_limit = EXCLUDED.file_size_limit, allowed_mime_types = EXCLUDED.allowed_mime_types; INSERT INTO storage.buckets (id, name, public, file_size_limit, allowed_mime_types) VALUES ( 'event-images', 'event-images', true, 10485760, ARRAY['image/jpeg', 'image/png', 'image/webp'] ) ON CONFLICT (id) DO UPDATE SET public = true, file_size_limit = EXCLUDED.file_size_limit, allowed_mime_types = EXCLUDED.allowed_mime_types; RAISE NOTICE 'Storage buckets ensured'; END IF; END $$; -- ============================================ -- 3. NOTIFICATIONS TABLE (added post-migration-016) -- ============================================ CREATE TABLE IF NOT EXISTS public.notifications ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), member_id UUID NOT NULL REFERENCES public.members(id) ON DELETE CASCADE, type TEXT NOT NULL CHECK (type IN ('welcome', 'event', 'payment', 'membership', 'system', 'announcement')), title TEXT NOT NULL, message TEXT NOT NULL, link TEXT, read_at TIMESTAMPTZ, created_at TIMESTAMPTZ DEFAULT NOW() ); CREATE INDEX IF NOT EXISTS idx_notifications_member ON public.notifications(member_id); CREATE INDEX IF NOT EXISTS idx_notifications_unread ON public.notifications(member_id) WHERE read_at IS NULL; CREATE INDEX IF NOT EXISTS idx_notifications_created ON public.notifications(created_at DESC); ALTER TABLE public.notifications ENABLE ROW LEVEL SECURITY; -- Idempotent policy creation DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_policy WHERE polrelid = 'public.notifications'::regclass AND polname = 'Members can view own notifications') THEN CREATE POLICY "Members can view own notifications" ON public.notifications FOR SELECT TO authenticated USING (member_id = auth.uid()); END IF; IF NOT EXISTS (SELECT 1 FROM pg_policy WHERE polrelid = 'public.notifications'::regclass AND polname = 'Members can update own notifications') THEN CREATE POLICY "Members can update own notifications" ON public.notifications FOR UPDATE TO authenticated USING (member_id = auth.uid()); END IF; IF NOT EXISTS (SELECT 1 FROM pg_policy WHERE polrelid = 'public.notifications'::regclass AND polname = 'Admin can manage all notifications') THEN CREATE POLICY "Admin can manage all notifications" ON public.notifications FOR ALL TO authenticated USING (EXISTS (SELECT 1 FROM public.members WHERE id = auth.uid() AND role = 'admin')) WITH CHECK (EXISTS (SELECT 1 FROM public.members WHERE id = auth.uid() AND role = 'admin')); END IF; END $$; GRANT SELECT, UPDATE ON public.notifications TO authenticated; GRANT ALL ON public.notifications TO service_role; -- Welcome notification trigger CREATE OR REPLACE FUNCTION create_welcome_notification() RETURNS TRIGGER AS $$ BEGIN INSERT INTO public.notifications (member_id, type, title, message, link) VALUES ( NEW.id, 'welcome', 'Welcome to Monaco USA!', 'Thank you for joining our community. Complete your profile and explore upcoming events.', '/profile' ); RETURN NEW; END; $$ LANGUAGE plpgsql SECURITY DEFINER; DROP TRIGGER IF EXISTS on_member_created_notification ON public.members; CREATE TRIGGER on_member_created_notification AFTER INSERT ON public.members FOR EACH ROW EXECUTE FUNCTION create_welcome_notification(); -- ============================================ -- 4. MIGRATIONS 017-025 (idempotent) -- ============================================ -- --- Migration 017: Fix RLS role escalation --- -- (Superseded by 019, kept for reference only) -- --- Migration 018: Atomic Member ID Generation --- DO $$ DECLARE max_num INTEGER; BEGIN IF NOT EXISTS (SELECT 1 FROM pg_sequences WHERE schemaname = 'public' AND sequencename = 'member_id_seq') THEN SELECT COALESCE( MAX( CAST( SUBSTRING(member_id FROM '[0-9]+$') AS INTEGER ) ), 0 ) INTO max_num FROM public.members; EXECUTE format('CREATE SEQUENCE member_id_seq START WITH %s', max_num + 1); RAISE NOTICE 'Created member_id_seq starting at %', max_num + 1; END IF; END $$; CREATE OR REPLACE FUNCTION generate_member_id() RETURNS TRIGGER AS $$ DECLARE next_num INTEGER; current_year TEXT; BEGIN IF NEW.member_id IS NOT NULL THEN RETURN NEW; END IF; next_num := NEXTVAL('member_id_seq'); current_year := EXTRACT(YEAR FROM CURRENT_DATE)::TEXT; NEW.member_id := 'MUSA-' || current_year || '-' || LPAD(next_num::TEXT, 4, '0'); RETURN NEW; END; $$ LANGUAGE plpgsql; DROP TRIGGER IF EXISTS set_member_id ON public.members; CREATE TRIGGER set_member_id BEFORE INSERT ON public.members FOR EACH ROW WHEN (NEW.member_id IS NULL) EXECUTE FUNCTION generate_member_id(); CREATE INDEX IF NOT EXISTS idx_members_member_id ON public.members(member_id); -- --- Migration 019: Fix admin role assignment --- DROP POLICY IF EXISTS "Users can update own profile" ON public.members; DROP POLICY IF EXISTS "Members can update own non-role fields" ON public.members; DROP POLICY IF EXISTS "Members can update own profile" ON public.members; DROP POLICY IF EXISTS "Admins can update other members" ON public.members; CREATE POLICY "Members can update own profile" ON public.members FOR UPDATE TO authenticated USING (auth.uid() = id) WITH CHECK ( auth.uid() = id AND role = (SELECT role FROM public.members WHERE id = auth.uid()) ); CREATE POLICY "Admins can update other members" ON public.members FOR UPDATE TO authenticated USING ( EXISTS ( SELECT 1 FROM public.members WHERE id = auth.uid() AND role = 'admin' ) ) WITH CHECK ( (id != auth.uid()) OR (id = auth.uid() AND role = (SELECT role FROM public.members WHERE id = auth.uid())) ); -- --- Migration 020: Approval workflow --- ALTER TABLE public.members ADD COLUMN IF NOT EXISTS approved_at TIMESTAMPTZ; ALTER TABLE public.members ADD COLUMN IF NOT EXISTS approved_by UUID REFERENCES auth.users(id); ALTER TABLE public.members ADD COLUMN IF NOT EXISTS rejected_at TIMESTAMPTZ; ALTER TABLE public.members ADD COLUMN IF NOT EXISTS rejected_by UUID REFERENCES auth.users(id); ALTER TABLE public.members ADD COLUMN IF NOT EXISTS rejection_reason TEXT; INSERT INTO public.email_templates (template_key, template_name, category, subject, body_html, body_text, is_active) VALUES ('member_approved', 'Member Approved', 'membership', 'Welcome to Monaco USA - Membership Approved!', '
Dear {{first_name}},
We are pleased to inform you that your membership application to Monaco USA has been approved!
Your member ID is: {{member_id}}
You now have full access to the member portal, including events, documents, and the member directory.
', 'Dear {{first_name}}, Your membership to Monaco USA has been approved! Your member ID is {{member_id}}.', true), ('member_rejected', 'Member Rejected', 'membership', 'Monaco USA - Membership Application Update', 'Dear {{first_name}},
Thank you for your interest in Monaco USA. After careful review, we regret to inform you that your membership application was not approved at this time.
{{reason}}
If you have questions, please contact us at info@monacousa.org.
', 'Dear {{first_name}}, Thank you for your interest in Monaco USA. After review, your membership application was not approved at this time.', true) ON CONFLICT (template_key) DO NOTHING; -- --- Migration 021: RSVP deadlines --- ALTER TABLE public.events ADD COLUMN IF NOT EXISTS rsvp_deadline TIMESTAMPTZ; ALTER TABLE public.events ADD COLUMN IF NOT EXISTS rsvp_deadline_enabled BOOLEAN DEFAULT FALSE; -- --- Migration 022: Directory privacy --- ALTER TABLE public.members ADD COLUMN IF NOT EXISTS directory_privacy JSONB DEFAULT '{ "show_email": true, "show_phone": true, "show_address": false, "show_nationality": true }'::jsonb; -- --- Migration 023: Document full-text search --- ALTER TABLE public.documents ADD COLUMN IF NOT EXISTS search_vector tsvector; UPDATE public.documents SET search_vector = to_tsvector('english', coalesce(title, '') || ' ' || coalesce(description, '') || ' ' || coalesce(file_name, '') ) WHERE search_vector IS NULL; CREATE INDEX IF NOT EXISTS idx_documents_search ON public.documents USING GIN (search_vector); CREATE OR REPLACE FUNCTION update_document_search_vector() RETURNS TRIGGER AS $$ BEGIN NEW.search_vector := to_tsvector('english', coalesce(NEW.title, '') || ' ' || coalesce(NEW.description, '') || ' ' || coalesce(NEW.file_name, '') ); RETURN NEW; END; $$ LANGUAGE plpgsql; DROP TRIGGER IF EXISTS trg_update_document_search ON public.documents; CREATE TRIGGER trg_update_document_search BEFORE INSERT OR UPDATE OF title, description, file_name ON public.documents FOR EACH ROW EXECUTE FUNCTION update_document_search_vector(); -- --- Migration 024: Cron execution logs --- CREATE TABLE IF NOT EXISTS public.cron_execution_logs ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), job_name TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'running' CHECK (status IN ('running', 'completed', 'failed')), started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), completed_at TIMESTAMPTZ, duration_ms INTEGER, result JSONB, error_message TEXT, triggered_by TEXT DEFAULT 'cron' ); CREATE INDEX IF NOT EXISTS idx_cron_logs_job_name ON public.cron_execution_logs (job_name, started_at DESC); CREATE INDEX IF NOT EXISTS idx_cron_logs_started ON public.cron_execution_logs (started_at DESC); ALTER TABLE public.cron_execution_logs ENABLE ROW LEVEL SECURITY; DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_policy WHERE polrelid = 'public.cron_execution_logs'::regclass AND polname = 'Admins can read cron logs') THEN CREATE POLICY "Admins can read cron logs" ON public.cron_execution_logs FOR SELECT TO authenticated USING (EXISTS (SELECT 1 FROM public.members WHERE members.user_id = auth.uid() AND members.role = 'admin')); END IF; IF NOT EXISTS (SELECT 1 FROM pg_policy WHERE polrelid = 'public.cron_execution_logs'::regclass AND polname = 'Service role can manage cron logs') THEN CREATE POLICY "Service role can manage cron logs" ON public.cron_execution_logs FOR ALL TO service_role USING (true) WITH CHECK (true); END IF; END $$; -- --- Migration 025: Bulk email broadcasts --- CREATE TABLE IF NOT EXISTS public.bulk_emails ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), subject TEXT NOT NULL, body TEXT NOT NULL, recipient_filter JSONB DEFAULT '{"target": "all"}'::jsonb, total_recipients INTEGER DEFAULT 0, sent_count INTEGER DEFAULT 0, failed_count INTEGER DEFAULT 0, status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'sending', 'completed', 'failed')), sent_by UUID REFERENCES auth.users(id), sent_by_name TEXT, created_at TIMESTAMPTZ DEFAULT NOW(), sent_at TIMESTAMPTZ, completed_at TIMESTAMPTZ ); CREATE INDEX IF NOT EXISTS idx_bulk_emails_created ON public.bulk_emails (created_at DESC); ALTER TABLE public.bulk_emails ENABLE ROW LEVEL SECURITY; DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_policy WHERE polrelid = 'public.bulk_emails'::regclass AND polname = 'Admins can manage bulk emails') THEN CREATE POLICY "Admins can manage bulk emails" ON public.bulk_emails FOR ALL TO authenticated USING (EXISTS (SELECT 1 FROM public.members WHERE members.user_id = auth.uid() AND members.role = 'admin')); END IF; IF NOT EXISTS (SELECT 1 FROM pg_policy WHERE polrelid = 'public.bulk_emails'::regclass AND polname = 'Service role full access to bulk emails') THEN CREATE POLICY "Service role full access to bulk emails" ON public.bulk_emails FOR ALL TO service_role USING (true) WITH CHECK (true); END IF; END $$; GRANT ALL ON public.cron_execution_logs TO service_role; GRANT ALL ON public.bulk_emails TO service_role; -- ============================================ -- 5. ENSURE SERVICE_ROLE ACCESS TO ALL TABLES -- ============================================ GRANT ALL ON ALL TABLES IN SCHEMA public TO service_role; GRANT ALL ON ALL SEQUENCES IN SCHEMA public TO service_role; ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO service_role; ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO service_role; -- ============================================ -- DONE -- ============================================ DO $$ BEGIN RAISE NOTICE '=== Post-deploy script completed successfully ==='; END $$;