Implement complete feature & security overhaul (21 items, 3 phases)
All checks were successful
Build and Push Docker Images / build-portal (push) Successful in 2m1s
Build and Push Docker Images / build-infra (docker/db, monacousa-db) (push) Successful in 1m17s
Build and Push Docker Images / build-infra (docker/kong, monacousa-kong) (push) Successful in 24s
Build and Push Docker Images / build-infra (docker/migrate, monacousa-migrate) (push) Successful in 1m0s

Phase 1 - Security & Data Integrity:
- Atomic member ID generation via PostgreSQL sequence (018)
- Rate limiting on signup, input sanitization (XSS prevention)
- Onboarding photo upload, document upload validation (magic bytes, MIME, size)
- RLS fix for admin role assignment without self-escalation (019)
- Email notification preferences enforcement
- Audit logging across all admin/board mutation actions
- CSV export for membership, payments, and events reports
- Member approval workflow with email notifications (020)

Phase 2 - Functionality & Monitoring:
- Directory privacy settings (022) with board-level filtering
- Document full-text search with PostgreSQL tsvector/GIN index (023)
- Cron job monitoring dashboard with manual trigger (024)
- Settings audit log tab
- Bulk email broadcast with recipient filtering and personalization (025)

Phase 3 - Feature Completeness:
- Event type filtering on events page
- RSVP deadline control for event organizers (021)

Also includes Kong CORS configuration fix.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-10 18:03:46 +01:00
parent fa99cda157
commit 5ff9f950a1
47 changed files with 2857 additions and 177 deletions

View File

@@ -0,0 +1,66 @@
-- Fix Race Condition in Member ID Generation
-- ============================================
-- Problem: Current implementation uses MAX() or COUNT() which allows duplicate IDs
-- if two registrations happen simultaneously.
--
-- Solution: Use PostgreSQL sequence for atomic ID generation.
-- Step 1: Create sequence for member IDs
-- Find the highest existing member number to set starting point
DO $$
DECLARE
max_num INTEGER;
BEGIN
-- Extract numeric part from existing member_ids (handles both MUSA-XXXX and MUSA-YYYY-XXXX formats)
SELECT COALESCE(
MAX(
CAST(
SUBSTRING(
member_id FROM '[0-9]+$' -- Get trailing digits
) AS INTEGER
)
),
0
) INTO max_num
FROM public.members;
-- Create sequence starting from next available number
EXECUTE format('CREATE SEQUENCE IF NOT EXISTS member_id_seq START WITH %s', max_num + 1);
END $$;
-- Step 2: Replace trigger function to use sequence
CREATE OR REPLACE FUNCTION generate_member_id()
RETURNS TRIGGER AS $$
DECLARE
next_num INTEGER;
current_year TEXT;
BEGIN
-- Only generate if member_id is not already set
IF NEW.member_id IS NOT NULL THEN
RETURN NEW;
END IF;
-- Get next number from sequence (atomic operation)
next_num := NEXTVAL('member_id_seq');
current_year := EXTRACT(YEAR FROM CURRENT_DATE)::TEXT;
-- Format: MUSA-YYYY-XXXX (matches application format)
NEW.member_id := 'MUSA-' || current_year || '-' || LPAD(next_num::TEXT, 4, '0');
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Step 3: Ensure trigger exists (should already exist from 001_initial_schema.sql)
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();
-- Step 4: Add index on member_id for faster lookups (if not exists)
CREATE INDEX IF NOT EXISTS idx_members_member_id ON public.members(member_id);
COMMENT ON SEQUENCE member_id_seq IS 'Atomic sequence for generating unique member IDs. Used by generate_member_id() trigger.';
COMMENT ON FUNCTION generate_member_id() IS 'Atomically generates member IDs using NEXTVAL(member_id_seq) to prevent race conditions.';

View File

@@ -0,0 +1,42 @@
-- Fix admin role assignment broken by migration 017
-- ============================================
-- Problem: Migration 017's WITH CHECK prevents admins from updating other members' roles
-- because the only UPDATE policy on members requires auth.uid() = id.
-- Solution: Replace the overly restrictive policy with a properly scoped one,
-- and add a separate policy for admins to update any member.
-- Drop the problematic policy from 017 if it exists
DROP POLICY IF EXISTS "Users can update own profile" ON public.members;
-- Also drop by the name used in 017 re-creation (same name, just being safe)
DROP POLICY IF EXISTS "Members can update own non-role fields" ON public.members;
-- Allow members to update their own non-role fields (profile info)
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())
);
-- Allow admins to update any member (including role changes) EXCEPT their own role
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 (
-- Admins can change any field on other members
(id != auth.uid())
OR
-- On their own record, admins can update non-role fields (role must stay the same)
(id = auth.uid() AND role = (SELECT role FROM public.members WHERE id = auth.uid()))
);

View File

@@ -0,0 +1,33 @@
-- Member approval workflow enhancements
-- ============================================
-- Add approval tracking columns to members table
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;
-- Add approval email templates
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;

View File

@@ -0,0 +1,9 @@
-- RSVP Deadline Control
-- ============================================
-- Allows event organizers to set an RSVP cutoff date/time.
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;
COMMENT ON COLUMN public.events.rsvp_deadline IS 'Optional RSVP cutoff date/time. RSVPs are blocked after this time.';
COMMENT ON COLUMN public.events.rsvp_deadline_enabled IS 'Whether the RSVP deadline is enforced for this event.';

View File

@@ -0,0 +1,14 @@
-- Migration 022: Directory Privacy Settings
-- Adds privacy controls for member directory visibility
-- Add directory_privacy JSONB column to members table
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;
-- Add comment
COMMENT ON COLUMN public.members.directory_privacy IS 'Controls which fields are visible in the member directory. Admins always see all fields.';

View File

@@ -0,0 +1,38 @@
-- Migration 023: Document Full-Text Search
-- Adds tsvector column and GIN index for fast document searching
-- Add search vector column
ALTER TABLE public.documents
ADD COLUMN IF NOT EXISTS search_vector tsvector;
-- Populate existing documents
UPDATE public.documents
SET search_vector = to_tsvector('english',
coalesce(title, '') || ' ' ||
coalesce(description, '') || ' ' ||
coalesce(file_name, '')
);
-- Create GIN index for fast full-text search
CREATE INDEX IF NOT EXISTS idx_documents_search
ON public.documents USING GIN (search_vector);
-- Create trigger to keep search_vector updated
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();

View File

@@ -0,0 +1,45 @@
-- Migration 024: Cron Execution Logs
-- Tracks cron job execution history for monitoring
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'
);
-- Index for quick lookups
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);
-- Enable RLS
ALTER TABLE public.cron_execution_logs ENABLE ROW LEVEL SECURITY;
-- Only admins can read cron logs
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'
)
);
-- Service role can insert/update (for cron endpoints)
CREATE POLICY "Service role can manage cron logs"
ON public.cron_execution_logs
FOR ALL
TO service_role
USING (true)
WITH CHECK (true);
-- Auto-cleanup: keep only 90 days of logs
COMMENT ON TABLE public.cron_execution_logs IS 'Tracks cron job execution history. Entries older than 90 days should be periodically purged.';

View File

@@ -0,0 +1,44 @@
-- Migration 025: Bulk Email Broadcasts
-- Tracks bulk email campaigns sent to members
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
);
-- Index for listing
CREATE INDEX IF NOT EXISTS idx_bulk_emails_created ON public.bulk_emails (created_at DESC);
-- Enable RLS
ALTER TABLE public.bulk_emails ENABLE ROW LEVEL SECURITY;
-- Only admins can manage bulk emails
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'
)
);
CREATE POLICY "Service role full access to bulk emails"
ON public.bulk_emails
FOR ALL
TO service_role
USING (true)
WITH CHECK (true);