Add migrations 018-025 to post-deploy.sql for automatic deployment
Build and Push Docker Images / build-portal (push) Successful in 3m5s Details
Build and Push Docker Images / build-infra (docker/db, monacousa-db) (push) Successful in 1m3s Details
Build and Push Docker Images / build-infra (docker/kong, monacousa-kong) (push) Successful in 22s Details
Build and Push Docker Images / build-infra (docker/migrate, monacousa-migrate) (push) Successful in 1m1s Details

All new migrations are now embedded in post-deploy.sql (idempotent),
so they run automatically on `docker compose up` via the migrate container.
Both deploy/ and docker/migrate/ copies are kept in sync.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Matt 2026-02-10 18:11:02 +01:00
parent 5ff9f950a1
commit f9364d2176
2 changed files with 444 additions and 8 deletions

View File

@ -198,19 +198,237 @@ CREATE TRIGGER on_member_created_notification
EXECUTE FUNCTION create_welcome_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; DROP POLICY IF EXISTS "Users can update own profile" ON public.members;
CREATE POLICY "Users can update own profile" DROP POLICY IF EXISTS "Members can update own non-role fields" ON public.members;
ON public.members FOR UPDATE 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 TO authenticated
USING (auth.uid() = id) USING (auth.uid() = id)
WITH CHECK ( WITH CHECK (
auth.uid() = id 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!',
'<h2 style="color: #22c55e; text-align: center;">Membership Approved!</h2>
<p style="color: #334155; line-height: 1.6;">Dear {{first_name}},</p>
<p style="color: #334155; line-height: 1.6;">We are pleased to inform you that your membership application to Monaco USA has been <strong>approved</strong>!</p>
<p style="color: #334155; line-height: 1.6;">Your member ID is: <strong>{{member_id}}</strong></p>
<p style="color: #334155; line-height: 1.6;">You now have full access to the member portal, including events, documents, and the member directory.</p>
<div style="text-align: center; margin: 24px 0;">
<a href="{{site_url}}/dashboard" style="background: #CE1126; color: white; padding: 12px 24px; border-radius: 8px; text-decoration: none; font-weight: bold;">Visit Your Dashboard</a>
</div>',
'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',
'<h2 style="color: #ef4444; text-align: center;">Application Update</h2>
<p style="color: #334155; line-height: 1.6;">Dear {{first_name}},</p>
<p style="color: #334155; line-height: 1.6;">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.</p>
<p style="color: #334155; line-height: 1.6;">{{reason}}</p>
<p style="color: #334155; line-height: 1.6;">If you have questions, please contact us at info@monacousa.org.</p>',
'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 -- 5. ENSURE SERVICE_ROLE ACCESS TO ALL TABLES
-- ============================================ -- ============================================

View File

@ -198,19 +198,237 @@ CREATE TRIGGER on_member_created_notification
EXECUTE FUNCTION create_welcome_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; DROP POLICY IF EXISTS "Users can update own profile" ON public.members;
CREATE POLICY "Users can update own profile" DROP POLICY IF EXISTS "Members can update own non-role fields" ON public.members;
ON public.members FOR UPDATE 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 TO authenticated
USING (auth.uid() = id) USING (auth.uid() = id)
WITH CHECK ( WITH CHECK (
auth.uid() = id 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!',
'<h2 style="color: #22c55e; text-align: center;">Membership Approved!</h2>
<p style="color: #334155; line-height: 1.6;">Dear {{first_name}},</p>
<p style="color: #334155; line-height: 1.6;">We are pleased to inform you that your membership application to Monaco USA has been <strong>approved</strong>!</p>
<p style="color: #334155; line-height: 1.6;">Your member ID is: <strong>{{member_id}}</strong></p>
<p style="color: #334155; line-height: 1.6;">You now have full access to the member portal, including events, documents, and the member directory.</p>
<div style="text-align: center; margin: 24px 0;">
<a href="{{site_url}}/dashboard" style="background: #CE1126; color: white; padding: 12px 24px; border-radius: 8px; text-decoration: none; font-weight: bold;">Visit Your Dashboard</a>
</div>',
'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',
'<h2 style="color: #ef4444; text-align: center;">Application Update</h2>
<p style="color: #334155; line-height: 1.6;">Dear {{first_name}},</p>
<p style="color: #334155; line-height: 1.6;">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.</p>
<p style="color: #334155; line-height: 1.6;">{{reason}}</p>
<p style="color: #334155; line-height: 1.6;">If you have questions, please contact us at info@monacousa.org.</p>',
'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 -- 5. ENSURE SERVICE_ROLE ACCESS TO ALL TABLES
-- ============================================ -- ============================================