diff --git a/deploy/post-deploy.sql b/deploy/post-deploy.sql index 21ff768..05924f4 100644 --- a/deploy/post-deploy.sql +++ b/deploy/post-deploy.sql @@ -198,19 +198,237 @@ CREATE TRIGGER on_member_created_notification EXECUTE FUNCTION create_welcome_notification(); -- ============================================ --- 4. MIGRATION 017: Fix RLS role escalation +-- 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; -CREATE POLICY "Users can update own profile" - ON public.members FOR UPDATE +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 m.role FROM public.members m WHERE m.id = auth.uid()) + 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!', + '

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.

+
+ Visit Your Dashboard +
', + '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', + '

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 -- ============================================ diff --git a/docker/migrate/post-deploy.sql b/docker/migrate/post-deploy.sql index 21ff768..05924f4 100644 --- a/docker/migrate/post-deploy.sql +++ b/docker/migrate/post-deploy.sql @@ -198,19 +198,237 @@ CREATE TRIGGER on_member_created_notification EXECUTE FUNCTION create_welcome_notification(); -- ============================================ --- 4. MIGRATION 017: Fix RLS role escalation +-- 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; -CREATE POLICY "Users can update own profile" - ON public.members FOR UPDATE +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 m.role FROM public.members m WHERE m.id = auth.uid()) + 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!', + '

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.

+
+ Visit Your Dashboard +
', + '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', + '

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 -- ============================================