Fix board members 0-count, bulk email 0-recipients, 502 on cold start, and duplicate statuses
Build and Push Docker Images / build-portal (push) Successful in 1m54s Details
Build and Push Docker Images / build-infra (docker/db, monacousa-db) (push) Successful in 59s Details
Build and Push Docker Images / build-infra (docker/kong, monacousa-kong) (push) Successful in 23s Details
Build and Push Docker Images / build-infra (docker/migrate, monacousa-migrate) (push) Successful in 59s Details

- Board members: switch to supabaseAdmin to bypass dues_payments RLS on members_with_dues view
- Bulk email: query members_with_dues view with status_name instead of nonexistent status column on members table
- 502 error: skip setupCheckHandle DB query for login/signup/public/static routes, increase cache TTL to 5min
- Duplicate statuses: add migration 026 to deduplicate Active/active entries, add defensive dedup in admin members loader

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Matt 2026-02-10 20:17:37 +01:00
parent 0e04d016da
commit 549ace87c6
5 changed files with 61 additions and 15 deletions

View File

@ -9,7 +9,7 @@ import { supabaseAdmin } from '$lib/server/supabase';
// Cache for setup check to avoid hitting DB on every request // Cache for setup check to avoid hitting DB on every request
let setupCheckCache: { needsSetup: boolean; checkedAt: number } | null = null; let setupCheckCache: { needsSetup: boolean; checkedAt: number } | null = null;
const SETUP_CACHE_TTL = 60000; // 1 minute cache const SETUP_CACHE_TTL = 300000; // 5 minute cache
/** /**
* Invalidate the setup check cache * Invalidate the setup check cache
@ -110,8 +110,20 @@ const supabaseHandle: Handle = async ({ event, resolve }) => {
const setupCheckHandle: Handle = async ({ event, resolve }) => { const setupCheckHandle: Handle = async ({ event, resolve }) => {
const path = event.url.pathname; const path = event.url.pathname;
// Always allow access to setup page and static assets // Always allow access to setup, auth, api, login, signup, public pages, and static assets
if (path === '/setup' || path.startsWith('/api/') || path.startsWith('/auth/')) { if (
path === '/setup' ||
path.startsWith('/api/') ||
path.startsWith('/auth/') ||
path === '/login' ||
path === '/signup' ||
path === '/forgot-password' ||
path === '/logout' ||
path.startsWith('/public/') ||
path.startsWith('/join') ||
path.startsWith('/_app/') ||
path.includes('.')
) {
return resolve(event); return resolve(event);
} }

View File

@ -14,14 +14,15 @@ export const load: PageServerLoad = async ({ locals }) => {
.limit(20); .limit(20);
// Get recipient counts for filter preview // Get recipient counts for filter preview
// Use members_with_dues view which has status_name from the joined membership_statuses table
const { data: members } = await supabaseAdmin const { data: members } = await supabaseAdmin
.from('members') .from('members_with_dues')
.select('id, role, status') .select('id, role, status_name, email')
.not('email', 'is', null); .not('email', 'is', null);
const recipientCounts = { const recipientCounts = {
all: members?.length || 0, all: members?.length || 0,
active: members?.filter((m: any) => m.status === 'active').length || 0, active: members?.filter((m: any) => m.status_name === 'active').length || 0,
board: members?.filter((m: any) => m.role === 'board' || m.role === 'admin').length || 0, board: members?.filter((m: any) => m.role === 'board' || m.role === 'admin').length || 0,
admin: members?.filter((m: any) => m.role === 'admin').length || 0 admin: members?.filter((m: any) => m.role === 'admin').length || 0
}; };
@ -52,14 +53,14 @@ export const actions: Actions = {
return fail(400, { error: 'Email body must be at least 10 characters' }); return fail(400, { error: 'Email body must be at least 10 characters' });
} }
// Build recipient query // Build recipient query - use view for status filtering
let query = supabaseAdmin let query = supabaseAdmin
.from('members') .from('members_with_dues')
.select('id, email, first_name, last_name, role, status'); .select('id, email, first_name, last_name, role, status_name');
switch (target) { switch (target) {
case 'active': case 'active':
query = query.eq('status', 'active'); query = query.eq('status_name', 'active');
break; break;
case 'board': case 'board':
query = query.in('role', ['board', 'admin']); query = query.in('role', ['board', 'admin']);

View File

@ -37,12 +37,20 @@ export const load: PageServerLoad = async ({ locals, url }) => {
filteredMembers = filteredMembers.filter((m: any) => m.status_name === statusFilter); filteredMembers = filteredMembers.filter((m: any) => m.status_name === statusFilter);
} }
// Load membership statuses for dropdown // Load membership statuses for dropdown (deduplicate by display_name)
const { data: statuses } = await supabaseAdmin const { data: rawStatuses } = await supabaseAdmin
.from('membership_statuses') .from('membership_statuses')
.select('*') .select('*')
.order('sort_order', { ascending: true }); .order('sort_order', { ascending: true });
const seen = new Set<string>();
const statuses = (rawStatuses || []).filter((s: any) => {
const key = s.display_name.toLowerCase();
if (seen.has(key)) return false;
seen.add(key);
return true;
});
// Calculate stats // Calculate stats
const stats = { const stats = {
total: members?.length || 0, total: members?.length || 0,

View File

@ -1,4 +1,5 @@
import type { PageServerLoad } from './$types'; import type { PageServerLoad } from './$types';
import { supabaseAdmin } from '$lib/server/supabase';
export const load: PageServerLoad = async ({ locals, url }) => { export const load: PageServerLoad = async ({ locals, url }) => {
const searchQuery = url.searchParams.get('search') || ''; const searchQuery = url.searchParams.get('search') || '';
@ -9,8 +10,8 @@ export const load: PageServerLoad = async ({ locals, url }) => {
const { member } = await locals.safeGetSession(); const { member } = await locals.safeGetSession();
const isPrivileged = member?.role === 'admin' || member?.role === 'board'; const isPrivileged = member?.role === 'admin' || member?.role === 'board';
// Build the query - select only needed columns to avoid exposing sensitive fields // Use admin client to bypass RLS on the view (dues_payments RLS blocks the join)
let query = locals.supabase let query = supabaseAdmin
.from('members_with_dues') .from('members_with_dues')
.select('id, member_id, first_name, last_name, email, phone, role, status_name, type_name, avatar_url, created_at, nationality, address, directory_privacy') .select('id, member_id, first_name, last_name, email, phone, role, status_name, type_name, avatar_url, created_at, nationality, address, directory_privacy')
.order('last_name', { ascending: true }); .order('last_name', { ascending: true });
@ -40,7 +41,7 @@ export const load: PageServerLoad = async ({ locals, url }) => {
} }
// Get membership statuses for filter dropdown // Get membership statuses for filter dropdown
const { data: statuses } = await locals.supabase const { data: statuses } = await supabaseAdmin
.from('membership_statuses') .from('membership_statuses')
.select('*') .select('*')
.order('sort_order', { ascending: true }); .order('sort_order', { ascending: true });

View File

@ -0,0 +1,24 @@
-- Fix duplicate membership statuses
-- The database has both "Active" (capitalized name) and "active" (lowercase) entries.
-- The seed data only creates "active" - the "Active" entry was added manually.
-- We need to:
-- 1. Move any members pointing to the duplicate "Active" status to the original "active" status
-- 2. Delete the duplicate "Active" entry
-- First, update any members that reference the duplicate "Active" (capitalized) status
-- to point to the original "active" (lowercase) status
UPDATE public.members
SET membership_status_id = (
SELECT id FROM public.membership_statuses WHERE name = 'active' LIMIT 1
)
WHERE membership_status_id IN (
SELECT id FROM public.membership_statuses WHERE name = 'Active'
);
-- Delete the duplicate "Active" (capitalized) status entry
DELETE FROM public.membership_statuses WHERE name = 'Active';
-- Ensure the remaining "active" status has correct sort_order
UPDATE public.membership_statuses
SET sort_order = 2, display_name = 'Active', color = '#22c55e'
WHERE name = 'active';