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
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:
@@ -36,6 +36,34 @@ services:
|
||||
preserve_host: false
|
||||
plugins:
|
||||
- name: cors
|
||||
config:
|
||||
origins:
|
||||
- https://portal.monacousa.org
|
||||
- https://monacousa.org
|
||||
- http://localhost:7453
|
||||
- http://localhost:3000
|
||||
methods:
|
||||
- GET
|
||||
- POST
|
||||
- PUT
|
||||
- PATCH
|
||||
- DELETE
|
||||
- OPTIONS
|
||||
headers:
|
||||
- Accept
|
||||
- Accept-Version
|
||||
- Authorization
|
||||
- Content-Length
|
||||
- Content-Type
|
||||
- Date
|
||||
- X-Auth-Token
|
||||
- apikey
|
||||
- x-client-info
|
||||
exposed_headers:
|
||||
- Content-Length
|
||||
- Content-Range
|
||||
credentials: true
|
||||
max_age: 3600
|
||||
|
||||
## Auth Service (GoTrue)
|
||||
- name: auth-v1-open
|
||||
@@ -47,6 +75,34 @@ services:
|
||||
- /auth/v1/verify
|
||||
plugins:
|
||||
- name: cors
|
||||
config:
|
||||
origins:
|
||||
- https://portal.monacousa.org
|
||||
- https://monacousa.org
|
||||
- http://localhost:7453
|
||||
- http://localhost:3000
|
||||
methods:
|
||||
- GET
|
||||
- POST
|
||||
- PUT
|
||||
- PATCH
|
||||
- DELETE
|
||||
- OPTIONS
|
||||
headers:
|
||||
- Accept
|
||||
- Accept-Version
|
||||
- Authorization
|
||||
- Content-Length
|
||||
- Content-Type
|
||||
- Date
|
||||
- X-Auth-Token
|
||||
- apikey
|
||||
- x-client-info
|
||||
exposed_headers:
|
||||
- Content-Length
|
||||
- Content-Range
|
||||
credentials: true
|
||||
max_age: 3600
|
||||
|
||||
- name: auth-v1-open-callback
|
||||
url: http://auth:9999/callback
|
||||
@@ -57,6 +113,34 @@ services:
|
||||
- /auth/v1/callback
|
||||
plugins:
|
||||
- name: cors
|
||||
config:
|
||||
origins:
|
||||
- https://portal.monacousa.org
|
||||
- https://monacousa.org
|
||||
- http://localhost:7453
|
||||
- http://localhost:3000
|
||||
methods:
|
||||
- GET
|
||||
- POST
|
||||
- PUT
|
||||
- PATCH
|
||||
- DELETE
|
||||
- OPTIONS
|
||||
headers:
|
||||
- Accept
|
||||
- Accept-Version
|
||||
- Authorization
|
||||
- Content-Length
|
||||
- Content-Type
|
||||
- Date
|
||||
- X-Auth-Token
|
||||
- apikey
|
||||
- x-client-info
|
||||
exposed_headers:
|
||||
- Content-Length
|
||||
- Content-Range
|
||||
credentials: true
|
||||
max_age: 3600
|
||||
|
||||
- name: auth-v1-open-authorize
|
||||
url: http://auth:9999/authorize
|
||||
@@ -67,6 +151,34 @@ services:
|
||||
- /auth/v1/authorize
|
||||
plugins:
|
||||
- name: cors
|
||||
config:
|
||||
origins:
|
||||
- https://portal.monacousa.org
|
||||
- https://monacousa.org
|
||||
- http://localhost:7453
|
||||
- http://localhost:3000
|
||||
methods:
|
||||
- GET
|
||||
- POST
|
||||
- PUT
|
||||
- PATCH
|
||||
- DELETE
|
||||
- OPTIONS
|
||||
headers:
|
||||
- Accept
|
||||
- Accept-Version
|
||||
- Authorization
|
||||
- Content-Length
|
||||
- Content-Type
|
||||
- Date
|
||||
- X-Auth-Token
|
||||
- apikey
|
||||
- x-client-info
|
||||
exposed_headers:
|
||||
- Content-Length
|
||||
- Content-Range
|
||||
credentials: true
|
||||
max_age: 3600
|
||||
|
||||
- name: auth-v1
|
||||
url: http://auth:9999/
|
||||
@@ -77,6 +189,34 @@ services:
|
||||
- /auth/v1/
|
||||
plugins:
|
||||
- name: cors
|
||||
config:
|
||||
origins:
|
||||
- https://portal.monacousa.org
|
||||
- https://monacousa.org
|
||||
- http://localhost:7453
|
||||
- http://localhost:3000
|
||||
methods:
|
||||
- GET
|
||||
- POST
|
||||
- PUT
|
||||
- PATCH
|
||||
- DELETE
|
||||
- OPTIONS
|
||||
headers:
|
||||
- Accept
|
||||
- Accept-Version
|
||||
- Authorization
|
||||
- Content-Length
|
||||
- Content-Type
|
||||
- Date
|
||||
- X-Auth-Token
|
||||
- apikey
|
||||
- x-client-info
|
||||
exposed_headers:
|
||||
- Content-Length
|
||||
- Content-Range
|
||||
credentials: true
|
||||
max_age: 3600
|
||||
- name: key-auth
|
||||
config:
|
||||
hide_credentials: false
|
||||
@@ -97,6 +237,34 @@ services:
|
||||
- /rest/v1/
|
||||
plugins:
|
||||
- name: cors
|
||||
config:
|
||||
origins:
|
||||
- https://portal.monacousa.org
|
||||
- https://monacousa.org
|
||||
- http://localhost:7453
|
||||
- http://localhost:3000
|
||||
methods:
|
||||
- GET
|
||||
- POST
|
||||
- PUT
|
||||
- PATCH
|
||||
- DELETE
|
||||
- OPTIONS
|
||||
headers:
|
||||
- Accept
|
||||
- Accept-Version
|
||||
- Authorization
|
||||
- Content-Length
|
||||
- Content-Type
|
||||
- Date
|
||||
- X-Auth-Token
|
||||
- apikey
|
||||
- x-client-info
|
||||
exposed_headers:
|
||||
- Content-Length
|
||||
- Content-Range
|
||||
credentials: true
|
||||
max_age: 3600
|
||||
- name: key-auth
|
||||
config:
|
||||
hide_credentials: false
|
||||
@@ -117,6 +285,34 @@ services:
|
||||
- /realtime/v1/websocket
|
||||
plugins:
|
||||
- name: cors
|
||||
config:
|
||||
origins:
|
||||
- https://portal.monacousa.org
|
||||
- https://monacousa.org
|
||||
- http://localhost:7453
|
||||
- http://localhost:3000
|
||||
methods:
|
||||
- GET
|
||||
- POST
|
||||
- PUT
|
||||
- PATCH
|
||||
- DELETE
|
||||
- OPTIONS
|
||||
headers:
|
||||
- Accept
|
||||
- Accept-Version
|
||||
- Authorization
|
||||
- Content-Length
|
||||
- Content-Type
|
||||
- Date
|
||||
- X-Auth-Token
|
||||
- apikey
|
||||
- x-client-info
|
||||
exposed_headers:
|
||||
- Content-Length
|
||||
- Content-Range
|
||||
credentials: true
|
||||
max_age: 3600
|
||||
- name: key-auth
|
||||
config:
|
||||
hide_credentials: false
|
||||
@@ -136,6 +332,34 @@ services:
|
||||
- /realtime/v1/
|
||||
plugins:
|
||||
- name: cors
|
||||
config:
|
||||
origins:
|
||||
- https://portal.monacousa.org
|
||||
- https://monacousa.org
|
||||
- http://localhost:7453
|
||||
- http://localhost:3000
|
||||
methods:
|
||||
- GET
|
||||
- POST
|
||||
- PUT
|
||||
- PATCH
|
||||
- DELETE
|
||||
- OPTIONS
|
||||
headers:
|
||||
- Accept
|
||||
- Accept-Version
|
||||
- Authorization
|
||||
- Content-Length
|
||||
- Content-Type
|
||||
- Date
|
||||
- X-Auth-Token
|
||||
- apikey
|
||||
- x-client-info
|
||||
exposed_headers:
|
||||
- Content-Length
|
||||
- Content-Range
|
||||
credentials: true
|
||||
max_age: 3600
|
||||
- name: key-auth
|
||||
config:
|
||||
hide_credentials: false
|
||||
@@ -156,6 +380,34 @@ services:
|
||||
- /storage/v1/object/public
|
||||
plugins:
|
||||
- name: cors
|
||||
config:
|
||||
origins:
|
||||
- https://portal.monacousa.org
|
||||
- https://monacousa.org
|
||||
- http://localhost:7453
|
||||
- http://localhost:3000
|
||||
methods:
|
||||
- GET
|
||||
- POST
|
||||
- PUT
|
||||
- PATCH
|
||||
- DELETE
|
||||
- OPTIONS
|
||||
headers:
|
||||
- Accept
|
||||
- Accept-Version
|
||||
- Authorization
|
||||
- Content-Length
|
||||
- Content-Type
|
||||
- Date
|
||||
- X-Auth-Token
|
||||
- apikey
|
||||
- x-client-info
|
||||
exposed_headers:
|
||||
- Content-Length
|
||||
- Content-Range
|
||||
credentials: true
|
||||
max_age: 3600
|
||||
|
||||
## Storage Service - All other operations (auth required)
|
||||
- name: storage-v1
|
||||
@@ -167,6 +419,34 @@ services:
|
||||
- /storage/v1/
|
||||
plugins:
|
||||
- name: cors
|
||||
config:
|
||||
origins:
|
||||
- https://portal.monacousa.org
|
||||
- https://monacousa.org
|
||||
- http://localhost:7453
|
||||
- http://localhost:3000
|
||||
methods:
|
||||
- GET
|
||||
- POST
|
||||
- PUT
|
||||
- PATCH
|
||||
- DELETE
|
||||
- OPTIONS
|
||||
headers:
|
||||
- Accept
|
||||
- Accept-Version
|
||||
- Authorization
|
||||
- Content-Length
|
||||
- Content-Type
|
||||
- Date
|
||||
- X-Auth-Token
|
||||
- apikey
|
||||
- x-client-info
|
||||
exposed_headers:
|
||||
- Content-Length
|
||||
- Content-Range
|
||||
credentials: true
|
||||
max_age: 3600
|
||||
- name: key-auth
|
||||
config:
|
||||
hide_credentials: false
|
||||
|
||||
66
supabase/migrations/018_atomic_member_id_generation.sql
Normal file
66
supabase/migrations/018_atomic_member_id_generation.sql
Normal 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.';
|
||||
42
supabase/migrations/019_fix_admin_role_assignment.sql
Normal file
42
supabase/migrations/019_fix_admin_role_assignment.sql
Normal 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()))
|
||||
);
|
||||
33
supabase/migrations/020_approval_email_templates.sql
Normal file
33
supabase/migrations/020_approval_email_templates.sql
Normal 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;
|
||||
9
supabase/migrations/021_rsvp_deadlines.sql
Normal file
9
supabase/migrations/021_rsvp_deadlines.sql
Normal 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.';
|
||||
14
supabase/migrations/022_directory_privacy.sql
Normal file
14
supabase/migrations/022_directory_privacy.sql
Normal 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.';
|
||||
38
supabase/migrations/023_document_search.sql
Normal file
38
supabase/migrations/023_document_search.sql
Normal 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();
|
||||
45
supabase/migrations/024_cron_execution_logs.sql
Normal file
45
supabase/migrations/024_cron_execution_logs.sql
Normal 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.';
|
||||
44
supabase/migrations/025_bulk_emails.sql
Normal file
44
supabase/migrations/025_bulk_emails.sql
Normal 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);
|
||||
Reference in New Issue
Block a user