147 KiB
Monaco USA Portal 2026 - Complete Rebuild
Project Overview
Rebuild the Monaco USA member portal from scratch in monacousa-portal-2026/ with modern architecture, beautiful UI, and improved functionality.
DETAILED FEATURE SPECIFICATIONS
1. MEMBER SYSTEM (Detailed)
1.1 Member ID Format
- Format:
MUSA-XXXX(sequential 4-digit number) - Examples: MUSA-0001, MUSA-0042, MUSA-1234
- Auto-generated on member creation
- Immutable once assigned
- Unique constraint in database
1.2 Membership Statuses (Admin-Configurable)
Admin can create, edit, and delete statuses via Settings.
Default Statuses (seeded on first run):
| Status | Color | Description | Is Default |
|---|---|---|---|
pending |
Yellow | New member, awaiting dues payment | Yes (for new signups) |
active |
Green | Dues paid, full access | No |
inactive |
Gray | Lapsed membership or suspended | No |
expired |
Red | Membership terminated | No |
Status Configuration Table:
CREATE TABLE public.membership_statuses (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL UNIQUE,
display_name TEXT NOT NULL,
color TEXT NOT NULL DEFAULT '#6b7280', -- Tailwind gray-500
description TEXT,
is_default BOOLEAN DEFAULT FALSE, -- Used for new signups
sort_order INTEGER DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW()
);
1.3 Roles/Tiers
Fixed 3-tier system (not configurable):
| Role | Access Level | Capabilities |
|---|---|---|
member |
Basic | View own profile, events, pay dues |
board |
Elevated | + Member directory, record payments, manage events |
admin |
Full | + User management, system settings, all data |
1.4 Required Member Fields
All fields marked as required during signup:
| Field | Type | Validation | Notes |
|---|---|---|---|
first_name |
Text | Min 2 chars | Required |
last_name |
Text | Min 2 chars | Required |
email |
Valid email format | Required, unique | |
phone |
Text | International format | Required |
date_of_birth |
Date | Must be 18+ years old | Required |
address |
Text | Min 10 chars | Required |
nationality |
Array | At least 1 country | Required, multiple allowed |
1.5 Optional Member Fields
| Field | Type | Notes |
|---|---|---|
avatar_url |
Text | Supabase Storage path |
membership_type_id |
UUID | Links to membership_types table |
notes |
Text | Admin-only notes about member |
1.6 Nationality Handling
- Multiple nationalities allowed
- Stored as PostgreSQL
TEXT[]array - Uses ISO 3166-1 alpha-2 country codes:
['FR', 'US', 'MC'] - UI shows country flags + names
- Searchable/filterable in directory
1.7 Profile Features
- Profile photo: Upload via Supabase Storage
- Max size: 5MB
- Formats: JPG, PNG, WebP
- Auto-resized to 256x256
- Stored at:
avatars/{member_id}/profile.{ext}
- No bio field (simplified profile)
- Members can edit: name, phone, address, nationality, photo
1.8 Member Directory
Visibility controlled by admin settings:
CREATE TABLE public.directory_settings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
field_name TEXT NOT NULL UNIQUE,
visible_to_members BOOLEAN DEFAULT FALSE,
visible_to_board BOOLEAN DEFAULT TRUE,
visible_to_admin BOOLEAN DEFAULT TRUE,
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Default visibility settings
INSERT INTO directory_settings (field_name, visible_to_members, visible_to_board) VALUES
('first_name', true, true),
('last_name', true, true),
('avatar_url', true, true),
('nationality', true, true),
('email', false, true),
('phone', false, true),
('address', false, true),
('date_of_birth', false, true),
('member_since', true, true),
('membership_status', false, true);
1.9 Member Signup Flow
┌─────────────┐ ┌──────────────┐ ┌─────────────┐
│ /signup │────▶│ Create Auth │────▶│ Email Verify│
│ Form │ │ User + Member│ │ Link Sent │
└─────────────┘ └──────────────┘ └─────────────┘
│
▼
┌──────────────┐ ┌─────────────┐
│ Status = │────▶│ Wait for │
│ 'pending' │ │ Dues Payment│
└──────────────┘ └─────────────┘
│
▼
┌─────────────┐
│ Board/Admin │
│ Records Dues│
└─────────────┘
│
▼
┌─────────────┐
│ Status = │
│ 'active' │
└─────────────┘
Key Points:
- Email verification required
- Status starts as
pending - Member gains
activestatus ONLY when first dues payment recorded - Pending members can log in but see limited dashboard
1.10 Admin Member Management
Two ways to add members:
Option A: Direct Add
- Admin fills out member form
- Admin sets temporary password OR sends password setup email
- Member record created with chosen status
- Member can log in immediately
Option B: Invite
- Admin enters email + basic info
- System sends invitation email with signup link
- Invitee completes signup form
- Status set based on invite settings
1.11 Membership Types (Admin-Configurable)
Admin can create membership tiers with different pricing:
CREATE TABLE public.membership_types (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL UNIQUE, -- 'regular', 'student', 'senior'
display_name TEXT NOT NULL, -- 'Regular Member', 'Student'
annual_dues DECIMAL(10,2) NOT NULL, -- 50.00, 25.00, etc.
description TEXT,
is_default BOOLEAN DEFAULT FALSE, -- Default for new signups
is_active BOOLEAN DEFAULT TRUE, -- Can be assigned
sort_order INTEGER DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Default membership types
INSERT INTO membership_types (name, display_name, annual_dues, is_default) VALUES
('regular', 'Regular Member', 50.00, true),
('student', 'Student', 25.00, false),
('senior', 'Senior (65+)', 35.00, false),
('family', 'Family', 75.00, false),
('honorary', 'Honorary Member', 0.00, false);
1.12 Complete Member Schema
CREATE TABLE public.members (
-- Identity
id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
member_id TEXT UNIQUE NOT NULL, -- MUSA-0001 format (auto-generated)
-- Required Personal Info
first_name TEXT NOT NULL,
last_name TEXT NOT NULL,
email TEXT UNIQUE NOT NULL,
phone TEXT NOT NULL,
date_of_birth DATE NOT NULL,
address TEXT NOT NULL,
nationality TEXT[] NOT NULL DEFAULT '{}',
-- Membership
role TEXT NOT NULL DEFAULT 'member'
CHECK (role IN ('member', 'board', 'admin')),
membership_status_id UUID REFERENCES public.membership_statuses(id),
membership_type_id UUID REFERENCES public.membership_types(id),
member_since DATE DEFAULT CURRENT_DATE,
-- Profile
avatar_url TEXT,
-- Admin
notes TEXT, -- Admin-only notes
-- Timestamps
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Auto-generate member_id trigger
CREATE OR REPLACE FUNCTION generate_member_id()
RETURNS TRIGGER AS $$
DECLARE
next_num INTEGER;
BEGIN
SELECT COALESCE(MAX(CAST(SUBSTRING(member_id FROM 6) AS INTEGER)), 0) + 1
INTO next_num
FROM public.members;
NEW.member_id := 'MUSA-' || LPAD(next_num::TEXT, 4, '0');
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER set_member_id
BEFORE INSERT ON public.members
FOR EACH ROW
WHEN (NEW.member_id IS NULL)
EXECUTE FUNCTION generate_member_id();
2. DUES/PAYMENTS SYSTEM (Detailed)
2.1 Dues Cycle
- Due date calculation: Payment date + 365 days
- Example: Payment on Jan 15, 2026 → Due Jan 15, 2027
- No proration: Full annual dues regardless of join date
2.2 Payment Methods
Bank transfer only (no online payments):
- IBAN tracking
- Reference number for matching
- Manual recording by Board/Admin
2.3 Payment Recording
Who can record payments:
- Board members
- Admins
Standard payment data tracked:
| Field | Type | Required | Description |
|---|---|---|---|
member_id |
UUID | Yes | Which member |
amount |
Decimal | Yes | Payment amount (€) |
payment_date |
Date | Yes | When payment was made |
due_date |
Date | Yes | When this payment period ends (auto-calculated) |
reference |
Text | No | Bank transfer reference |
payment_method |
Text | Yes | Always 'bank_transfer' for now |
recorded_by |
UUID | Yes | Board/Admin who recorded |
notes |
Text | No | Optional notes |
2.4 Dues Settings (Admin-Configurable)
CREATE TABLE public.dues_settings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
setting_key TEXT UNIQUE NOT NULL,
setting_value TEXT NOT NULL,
description TEXT,
updated_at TIMESTAMPTZ DEFAULT NOW(),
updated_by UUID REFERENCES public.members(id)
);
-- Default settings
INSERT INTO dues_settings (setting_key, setting_value, description) VALUES
('reminder_days_before', '30,7', 'Days before due date to send reminders (comma-separated)'),
('grace_period_days', '30', 'Days after due date before auto-inactive'),
('overdue_reminder_interval', '14', 'Days between overdue reminder emails'),
('payment_iban', 'MC58 1756 9000 0104 0050 1001 860', 'IBAN for dues payment'),
('payment_account_holder', 'ASSOCIATION MONACO USA', 'Account holder name'),
('payment_instructions', 'Please include your Member ID in the reference', 'Payment instructions');
2.5 Automatic Reminders
Reminder Schedule (configurable via settings):
- 30 days before due date: "Your dues are coming up"
- 7 days before due date: "Reminder: dues due in 1 week"
- On due date: "Your dues are now due"
- Every 14 days overdue: "Your dues are overdue" (until grace period ends)
Email Content Includes:
- Member name
- Amount due (from membership_type)
- Due date
- IBAN and account holder
- Payment reference suggestion (Member ID)
- Link to portal
Technical Implementation:
- Supabase Edge Function runs daily
- Checks all members for reminder triggers
- Logs sent emails in
email_logstable - Respects settings for intervals
2.6 Overdue Handling
Grace Period Flow:
Due Date Passed
│
▼
┌─────────────────────────────────────────┐
│ GRACE PERIOD (configurable, default 30 days) │
│ - Status remains 'active' │
│ - Overdue reminders sent │
│ - Flagged in dashboard │
└─────────────────────────────────────────┘
│
▼ (grace period ends)
┌─────────────────────────────────────────┐
│ AUTO STATUS CHANGE │
│ - Status → 'inactive' │
│ - Final notification email │
│ - Member loses active access │
└─────────────────────────────────────────┘
Supabase Edge Function for Auto-Update:
// Runs daily via cron
async function updateOverdueMembers() {
const gracePeriodDays = await getSetting('grace_period_days');
const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() - gracePeriodDays);
// Find members past grace period
const { data: overdueMembers } = await supabase
.from('members_with_dues')
.select('*')
.eq('membership_status', 'active')
.lt('current_due_date', cutoffDate.toISOString());
// Update each to inactive
for (const member of overdueMembers) {
await supabase
.from('members')
.update({ membership_status_id: inactiveStatusId })
.eq('id', member.id);
// Send final notification
await sendEmail(member.email, 'membership_lapsed', { ... });
}
}
2.7 Payment History (Member Visible)
Members can see their complete payment history:
Display includes:
- Payment date
- Amount paid
- Due date (period covered)
- Reference number
- Payment method
Members CANNOT see:
- Who recorded the payment
- Internal notes
- Other members' payments
2.8 Dues Dashboard (Board/Admin)
Overview Stats:
- Total members with current dues
- Members with dues due soon (next 30 days)
- Overdue members count
- Total collected this year
Filterable Member List:
- Filter by: status (current, due soon, overdue, never paid)
- Sort by: due date, days overdue, member name
- Quick actions: Record payment, Send reminder
Individual Member View:
- Full payment history
- Current dues status
- Quick record payment form
- Send manual reminder button
2.9 Complete Dues Schema
-- Dues payments table
CREATE TABLE public.dues_payments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
member_id UUID NOT NULL REFERENCES public.members(id) ON DELETE CASCADE,
amount DECIMAL(10,2) NOT NULL,
currency TEXT DEFAULT 'EUR',
payment_date DATE NOT NULL,
due_date DATE NOT NULL, -- Calculated: payment_date + 1 year
payment_method TEXT DEFAULT 'bank_transfer',
reference TEXT, -- Bank transfer reference
notes TEXT, -- Internal notes
recorded_by UUID NOT NULL REFERENCES public.members(id),
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Trigger to auto-calculate due_date
CREATE OR REPLACE FUNCTION calculate_due_date()
RETURNS TRIGGER AS $$
BEGIN
NEW.due_date := NEW.payment_date + INTERVAL '1 year';
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER set_due_date
BEFORE INSERT ON public.dues_payments
FOR EACH ROW
WHEN (NEW.due_date IS NULL)
EXECUTE FUNCTION calculate_due_date();
-- After payment: update member status to active
CREATE OR REPLACE FUNCTION update_member_status_on_payment()
RETURNS TRIGGER AS $$
DECLARE
active_status_id UUID;
BEGIN
-- Get active status ID
SELECT id INTO active_status_id
FROM public.membership_statuses
WHERE name = 'active';
-- Update member status
UPDATE public.members
SET membership_status_id = active_status_id,
updated_at = NOW()
WHERE id = NEW.member_id;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER activate_member_on_payment
AFTER INSERT ON public.dues_payments
FOR EACH ROW
EXECUTE FUNCTION update_member_status_on_payment();
-- Computed view for dues status
CREATE VIEW public.members_with_dues AS
SELECT
m.*,
ms.name as status_name,
ms.display_name as status_display_name,
ms.color as status_color,
mt.display_name as membership_type_name,
mt.annual_dues,
dp.last_payment_date,
dp.current_due_date,
CASE
WHEN dp.current_due_date IS NULL THEN 'never_paid'
WHEN dp.current_due_date < CURRENT_DATE THEN 'overdue'
WHEN dp.current_due_date < CURRENT_DATE + INTERVAL '30 days' THEN 'due_soon'
ELSE 'current'
END as dues_status,
CASE
WHEN dp.current_due_date < CURRENT_DATE
THEN (CURRENT_DATE - dp.current_due_date)::INTEGER
ELSE NULL
END as days_overdue,
CASE
WHEN dp.current_due_date >= CURRENT_DATE
THEN (dp.current_due_date - CURRENT_DATE)::INTEGER
ELSE NULL
END as days_until_due
FROM public.members m
LEFT JOIN public.membership_statuses ms ON m.membership_status_id = ms.id
LEFT JOIN public.membership_types mt ON m.membership_type_id = mt.id
LEFT JOIN LATERAL (
SELECT
payment_date as last_payment_date,
due_date as current_due_date
FROM public.dues_payments
WHERE member_id = m.id
ORDER BY due_date DESC
LIMIT 1
) dp ON true;
2.10 Email Templates for Dues
Types:
dues_reminder- Upcoming dues reminderdues_due_today- Dues due todaydues_overdue- Overdue reminderdues_lapsed- Membership lapsed (grace period ended)dues_received- Payment confirmation
Template Variables:
{{member_name}}- Full name{{member_id}}- MUSA-XXXX{{amount}}- Due amount{{due_date}}- Formatted date{{days_until_due}}or{{days_overdue}}{{iban}}- Payment IBAN{{account_holder}}- Account name{{portal_link}}- Link to portal
3. EVENTS SYSTEM (Detailed)
3.1 Event Types (Admin-Configurable)
CREATE TABLE public.event_types (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL UNIQUE,
display_name TEXT NOT NULL,
color TEXT NOT NULL DEFAULT '#3b82f6', -- Tailwind blue-500
icon TEXT, -- Lucide icon name
description TEXT,
is_active BOOLEAN DEFAULT TRUE,
sort_order INTEGER DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Default event types
INSERT INTO event_types (name, display_name, color, icon) VALUES
('social', 'Social Event', '#10b981', 'party-popper'),
('meeting', 'Meeting', '#6366f1', 'users'),
('fundraiser', 'Fundraiser', '#f59e0b', 'heart-handshake'),
('workshop', 'Workshop', '#8b5cf6', 'graduation-cap'),
('gala', 'Gala/Formal', '#ec4899', 'sparkles'),
('other', 'Other', '#6b7280', 'calendar');
3.2 Event Visibility
Visibility Options:
| Level | Who Can See | Description |
|---|---|---|
public |
Anyone | Visible on public events page (no login) |
members |
All logged-in members | Default for most events |
board |
Board + Admin only | Board meetings, internal events |
admin |
Admin only | Administrative events |
3.3 Event Pricing
Pricing Model:
- Each event can be free or paid
- Paid events have member price and non-member price
- Member pricing determined by
membership_type_id(if tiered pricing enabled) - Non-members pay non-member price always
Pricing Fields:
is_paid BOOLEAN DEFAULT FALSE,
member_price DECIMAL(10,2) DEFAULT 0,
non_member_price DECIMAL(10,2) DEFAULT 0,
pricing_notes TEXT -- "Includes dinner and drinks"
3.4 Guest/+1 Handling
Per-Event Configuration:
max_guests_per_member- 0, 1, 2, 3, or unlimited- Each RSVP tracks guest count and guest names
- Guests count toward total capacity
- Non-members can bring guests too (if enabled)
3.5 Non-Member (Public) RSVP
Flow for public events:
┌─────────────────┐ ┌──────────────────┐
│ Public Events │────▶│ Event Detail │
│ Page (no login) │ │ (public visible) │
└─────────────────┘ └──────────────────┘
│
▼
┌──────────────────┐
│ RSVP Form │
│ (no account) │
│ - Name │
│ - Email │
│ - Phone │
│ - Guest count │
│ - Guest names │
└──────────────────┘
│
▼
┌──────────────────┐
│ Payment Info │
│ (if paid event) │
│ - IBAN shown │
│ - Reference # │
└──────────────────┘
│
▼
┌──────────────────┐
│ RSVP Confirmed │
│ (pending payment)│
│ Email sent │
└──────────────────┘
Non-Member RSVP Table:
CREATE TABLE public.event_rsvps_public (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
event_id UUID NOT NULL REFERENCES public.events(id) ON DELETE CASCADE,
-- Contact info (required)
full_name TEXT NOT NULL,
email TEXT NOT NULL,
phone TEXT,
-- RSVP details
status TEXT NOT NULL DEFAULT 'confirmed'
CHECK (status IN ('confirmed', 'declined', 'maybe', 'waitlist', 'cancelled')),
guest_count INTEGER DEFAULT 0,
guest_names TEXT[],
-- Payment (for paid events)
payment_status TEXT DEFAULT 'not_required'
CHECK (payment_status IN ('not_required', 'pending', 'paid')),
payment_reference TEXT,
payment_amount DECIMAL(10,2),
-- Attendance
attended BOOLEAN DEFAULT FALSE,
-- Timestamps
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(event_id, email) -- One RSVP per email per event
);
3.6 RSVP Status Options
For Members and Non-Members:
| Status | Description |
|---|---|
confirmed |
Attending the event |
declined |
Not attending |
maybe |
Tentative/undecided |
waitlist |
Event full, on waitlist |
cancelled |
Cancelled RSVP |
3.7 Capacity & Waitlist
Capacity Management:
max_attendees- Total spots (null = unlimited)- Includes members + guests + non-members + their guests
- When full, new RSVPs go to waitlist
Auto-Promote Waitlist:
// Trigger when RSVP is cancelled or declined
async function promoteFromWaitlist(eventId: string) {
// Get event capacity
const event = await getEvent(eventId);
const currentCount = await getCurrentAttendeeCount(eventId);
if (event.max_attendees && currentCount >= event.max_attendees) {
return; // Still full
}
// Get oldest waitlist entry
const waitlisted = await supabase
.from('event_rsvps')
.select('*')
.eq('event_id', eventId)
.eq('status', 'waitlist')
.order('created_at', { ascending: true })
.limit(1)
.single();
if (waitlisted) {
// Promote to confirmed
await supabase
.from('event_rsvps')
.update({ status: 'confirmed' })
.eq('id', waitlisted.id);
// Send notification email
await sendEmail(waitlisted.member.email, 'waitlist_promoted', {
event_title: event.title,
event_date: event.start_datetime
});
}
}
3.8 Attendance Tracking
Check-in System:
- Board/Admin can mark attendance after event
- Checkbox per RSVP: attended yes/no
- Track attendance rate per event
- Member attendance history viewable
-- Add to RSVPs
attended BOOLEAN DEFAULT FALSE,
checked_in_at TIMESTAMPTZ,
checked_in_by UUID REFERENCES public.members(id)
3.9 Calendar Views
Available Views:
- Month - Traditional calendar grid
- Week - Weekly schedule view
- Day - Single day detailed view
- List - Upcoming events list
Using FullCalendar (SvelteKit compatible):
import Calendar from '@event-calendar/core';
import TimeGrid from '@event-calendar/time-grid';
import DayGrid from '@event-calendar/day-grid';
import List from '@event-calendar/list';
3.10 Event Schema
CREATE TABLE public.events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Basic Info
title TEXT NOT NULL,
description TEXT,
event_type_id UUID REFERENCES public.event_types(id),
-- Date/Time
start_datetime TIMESTAMPTZ NOT NULL,
end_datetime TIMESTAMPTZ NOT NULL,
all_day BOOLEAN DEFAULT FALSE,
timezone TEXT DEFAULT 'Europe/Monaco',
-- Location
location TEXT,
location_url TEXT, -- Google Maps link, etc.
-- Capacity
max_attendees INTEGER, -- null = unlimited
max_guests_per_member INTEGER DEFAULT 1,
-- Pricing
is_paid BOOLEAN DEFAULT FALSE,
member_price DECIMAL(10,2) DEFAULT 0,
non_member_price DECIMAL(10,2) DEFAULT 0,
pricing_notes TEXT,
-- Visibility
visibility TEXT NOT NULL DEFAULT 'members'
CHECK (visibility IN ('public', 'members', 'board', 'admin')),
-- Status
status TEXT NOT NULL DEFAULT 'published'
CHECK (status IN ('draft', 'published', 'cancelled', 'completed')),
-- Media
cover_image_url TEXT, -- Event banner/cover image
-- Meta
created_by UUID NOT NULL REFERENCES public.members(id),
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Member RSVPs
CREATE TABLE public.event_rsvps (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
event_id UUID NOT NULL REFERENCES public.events(id) ON DELETE CASCADE,
member_id UUID NOT NULL REFERENCES public.members(id) ON DELETE CASCADE,
status TEXT NOT NULL DEFAULT 'confirmed'
CHECK (status IN ('confirmed', 'declined', 'maybe', 'waitlist', 'cancelled')),
guest_count INTEGER DEFAULT 0,
guest_names TEXT[],
notes TEXT,
-- Payment (for paid events)
payment_status TEXT DEFAULT 'not_required'
CHECK (payment_status IN ('not_required', 'pending', 'paid')),
payment_reference TEXT,
payment_amount DECIMAL(10,2),
-- Attendance
attended BOOLEAN DEFAULT FALSE,
checked_in_at TIMESTAMPTZ,
checked_in_by UUID REFERENCES public.members(id),
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(event_id, member_id)
);
-- View for event with counts
CREATE VIEW public.events_with_counts AS
SELECT
e.*,
et.display_name as event_type_name,
et.color as event_type_color,
et.icon as event_type_icon,
COALESCE(member_rsvps.confirmed_count, 0) +
COALESCE(member_rsvps.guest_count, 0) +
COALESCE(public_rsvps.confirmed_count, 0) +
COALESCE(public_rsvps.guest_count, 0) as total_attendees,
COALESCE(member_rsvps.confirmed_count, 0) as member_count,
COALESCE(public_rsvps.confirmed_count, 0) as non_member_count,
COALESCE(member_rsvps.waitlist_count, 0) +
COALESCE(public_rsvps.waitlist_count, 0) as waitlist_count,
CASE
WHEN e.max_attendees IS NULL THEN FALSE
WHEN (COALESCE(member_rsvps.confirmed_count, 0) +
COALESCE(member_rsvps.guest_count, 0) +
COALESCE(public_rsvps.confirmed_count, 0) +
COALESCE(public_rsvps.guest_count, 0)) >= e.max_attendees THEN TRUE
ELSE FALSE
END as is_full
FROM public.events e
LEFT JOIN public.event_types et ON e.event_type_id = et.id
LEFT JOIN LATERAL (
SELECT
COUNT(*) FILTER (WHERE status = 'confirmed') as confirmed_count,
COALESCE(SUM(guest_count) FILTER (WHERE status = 'confirmed'), 0) as guest_count,
COUNT(*) FILTER (WHERE status = 'waitlist') as waitlist_count
FROM public.event_rsvps
WHERE event_id = e.id
) member_rsvps ON true
LEFT JOIN LATERAL (
SELECT
COUNT(*) FILTER (WHERE status = 'confirmed') as confirmed_count,
COALESCE(SUM(guest_count) FILTER (WHERE status = 'confirmed'), 0) as guest_count,
COUNT(*) FILTER (WHERE status = 'waitlist') as waitlist_count
FROM public.event_rsvps_public
WHERE event_id = e.id
) public_rsvps ON true;
3.11 Event Permissions
| Action | Member | Board | Admin |
|---|---|---|---|
| View public events | - | - | - |
| View member events | ✓ | ✓ | ✓ |
| View board events | - | ✓ | ✓ |
| View admin events | - | - | ✓ |
| RSVP to events | ✓ | ✓ | ✓ |
| Create events | - | ✓ | ✓ |
| Edit own events | - | ✓ | ✓ |
| Edit any event | - | - | ✓ |
| Delete events | - | - | ✓ |
| Manage RSVPs | - | ✓ | ✓ |
| Track attendance | - | ✓ | ✓ |
3.12 Event Email Notifications
Email Types:
event_created- New event announcement (for public/member events)event_reminder- Reminder before event (configurable: 1 day, 1 hour)event_updated- Event details changedevent_cancelled- Event cancelledrsvp_confirmation- RSVP receivedwaitlist_promoted- Promoted from waitlistevent_payment_reminder- Payment reminder for paid events
Template Variables:
{{event_title}},{{event_date}},{{event_time}}{{event_location}},{{event_description}}{{member_name}},{{guest_count}}{{payment_amount}},{{payment_iban}}{{rsvp_status}},{{portal_link}}
4. AUTH & DASHBOARDS (Detailed)
4.1 Authentication Method
Email/Password only (no social login):
- Standard email + password signup/login
- Email verification required
- Password reset via email
- Remember me option (extended session)
4.2 Login Page Design
Branded login with:
- Monaco USA logo
- Association tagline
- Login form (email, password, remember me)
- Links: Forgot password, Sign up
- Glass-morphism styling
- Responsive (mobile-friendly)
4.3 Auth Flow
┌──────────────────────────────────────────────────────────────┐
│ SIGNUP FLOW │
├──────────────────────────────────────────────────────────────┤
│ /signup │
│ ├── Full form (all required fields) │
│ ├── Supabase Auth: signUp(email, password) │
│ ├── Create member record (status: pending) │
│ ├── Send verification email │
│ └── Show "Check your email" message │
│ │
│ /auth/callback (email verification link) │
│ ├── Verify email token │
│ ├── Update email_verified = true │
│ └── Redirect to /login with success message │
└──────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────┐
│ LOGIN FLOW │
├──────────────────────────────────────────────────────────────┤
│ /login │
│ ├── Email + Password form │
│ ├── Supabase Auth: signInWithPassword() │
│ ├── Set session cookie (via Supabase SSR) │
│ ├── Fetch member record │
│ └── Redirect to /dashboard │
└──────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────┐
│ PASSWORD RESET │
├──────────────────────────────────────────────────────────────┤
│ /forgot-password │
│ ├── Email input form │
│ ├── Supabase Auth: resetPasswordForEmail() │
│ └── Show "Check your email" message │
│ │
│ /auth/reset-password (from email link) │
│ ├── New password form │
│ ├── Supabase Auth: updateUser({ password }) │
│ └── Redirect to /login with success │
└──────────────────────────────────────────────────────────────┘
4.4 Session Management
Supabase SSR Configuration:
// src/hooks.server.ts
export const handle: Handle = async ({ event, resolve }) => {
event.locals.supabase = createServerClient(
PUBLIC_SUPABASE_URL,
PUBLIC_SUPABASE_ANON_KEY,
{
cookies: {
getAll: () => event.cookies.getAll(),
setAll: (cookies) => cookies.forEach(({ name, value, options }) =>
event.cookies.set(name, value, { ...options, path: '/' })
)
}
}
);
event.locals.safeGetSession = async () => {
const { data: { session } } = await event.locals.supabase.auth.getSession();
if (!session) return { session: null, user: null, member: null };
const { data: { user } } = await event.locals.supabase.auth.getUser();
if (!user) return { session: null, user: null, member: null };
// Fetch member record
const { data: member } = await event.locals.supabase
.from('members_with_dues')
.select('*')
.eq('id', user.id)
.single();
return { session, user, member };
};
return resolve(event);
};
4.5 Navigation Structure
Desktop: Collapsible Sidebar
┌─────────────────────────────────────────────────────┐
│ ┌─────┐ │
│ │ │ Dashboard │
│ │LOGO │ ───────────────────────────────────── │
│ │ │ │
│ └─────┘ [Sidebar Navigation] [Content] │
│ │
│ 📊 Dashboard │
│ 👤 My Profile │
│ 📅 Events │
│ 💳 Payments │
│ │
│ ── Board ──────── (if board/admin) │
│ 👥 Members │
│ 📋 Dues Management │
│ 📅 Event Management │
│ │
│ ── Admin ──────── (if admin) │
│ ⚙️ Settings │
│ 👥 User Management │
│ 📄 Documents │
│ │
│ ───────────────── │
│ 🚪 Logout │
└─────────────────────────────────────────────────────┘
Mobile: Bottom Navigation Bar
┌─────────────────────────────────────┐
│ │
│ [Main Content] │
│ │
│ │
├─────────────────────────────────────┤
│ 🏠 📅 👤 ⚙️ ☰ │
│ Home Events Profile Settings More │
└─────────────────────────────────────┘
4.6 Unified Dashboard with Role Sections
Single /dashboard route with role-based sections:
<!-- routes/(app)/dashboard/+page.svelte -->
<script>
export let data; // { member, events, stats }
const { member } = data;
const isBoard = member.role === 'board' || member.role === 'admin';
const isAdmin = member.role === 'admin';
</script>
<!-- Everyone sees these -->
<WelcomeCard {member} />
<DuesStatusCard {member} />
<UpcomingEventsCard events={data.upcomingEvents} />
<!-- Board and Admin see these -->
{#if isBoard}
<Separator title="Board Tools" />
<MemberStatsCard stats={data.memberStats} />
<PendingMembersCard members={data.pendingMembers} />
<DuesOverviewCard overview={data.duesOverview} />
{/if}
<!-- Admin only sees these -->
{#if isAdmin}
<Separator title="Admin" />
<SystemHealthCard health={data.systemHealth} />
<RecentActivityCard activity={data.recentActivity} />
<QuickActionsCard />
{/if}
4.7 Member Dashboard Section
Components:
- Welcome Card - Greeting with name, membership status badge
- Dues Status Card - Current status, next due date, quick pay info
- Upcoming Events Card - Next 3-5 events with RSVP status
- Profile Quick View - Photo, basic info, edit link
Data Loaded:
// routes/(app)/dashboard/+page.server.ts
export const load = async ({ locals }) => {
const { member } = await locals.safeGetSession();
const upcomingEvents = await getUpcomingEventsForMember(member.id, 5);
return {
member,
upcomingEvents
};
};
4.8 Board Dashboard Section
Additional Components (visible to board/admin):
- Member Stats Card - Total, active, pending, inactive counts
- Pending Members Card - New signups awaiting approval/payment
- Dues Overview Card - Current, due soon, overdue breakdown
- Recent RSVPs Card - Latest event RSVPs
Board Stats:
interface BoardStats {
totalMembers: number;
activeMembers: number;
pendingMembers: number;
inactiveMembers: number;
duesSoon: number; // Due in next 30 days
duesOverdue: number; // Past due date
upcomingEvents: number;
pendingRsvps: number;
}
4.9 Admin Dashboard Section
Additional Components (admin only):
- System Health Card - Supabase status, email status
- Recent Activity Card - Latest logins, signups, payments
- Quick Actions Card - Add member, create event, send broadcast
- Alerts Card - Issues requiring attention
Admin Stats:
interface AdminStats extends BoardStats {
totalUsers: number; // Auth users
recentLogins: number; // Last 24 hours
failedLogins: number; // Last 24 hours
emailsSent: number; // This month
storageUsed: number; // MB
}
4.10 Route Protection
Layout-level guards using SvelteKit:
// routes/(app)/+layout.server.ts
import { redirect } from '@sveltejs/kit';
export const load = async ({ locals }) => {
const { session, member } = await locals.safeGetSession();
if (!session) {
throw redirect(303, '/login');
}
return { member };
};
// routes/(app)/board/+layout.server.ts
export const load = async ({ locals, parent }) => {
const { member } = await parent();
if (member.role !== 'board' && member.role !== 'admin') {
throw redirect(303, '/dashboard');
}
return {};
};
// routes/(app)/admin/+layout.server.ts
export const load = async ({ locals, parent }) => {
const { member } = await parent();
if (member.role !== 'admin') {
throw redirect(303, '/dashboard');
}
return {};
};
4.11 Responsive Breakpoints
| Breakpoint | Width | Layout |
|---|---|---|
| Mobile | < 640px | Bottom nav, stacked cards |
| Tablet | 640-1024px | Collapsed sidebar rail, 2-column |
| Desktop | > 1024px | Full sidebar, 3-column grid |
4.12 Dashboard Glass-Morphism Design
Glass Card Base Style:
.glass-card {
background: rgba(255, 255, 255, 0.7);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 16px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
}
.glass-card-dark {
background: rgba(0, 0, 0, 0.3);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.1);
}
Monaco Red Accent:
:root {
--monaco-red: #dc2626;
--monaco-red-light: #fee2e2;
--monaco-red-dark: #991b1b;
}
5. DOCUMENT STORAGE (Detailed)
5.1 Document Categories (Admin-Configurable)
CREATE TABLE public.document_categories (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL UNIQUE,
display_name TEXT NOT NULL,
description TEXT,
icon TEXT, -- Lucide icon name
sort_order INTEGER DEFAULT 0,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Default categories
INSERT INTO document_categories (name, display_name, icon) VALUES
('meeting_minutes', 'Meeting Minutes', 'file-text'),
('governance', 'Governance & Bylaws', 'scale'),
('legal', 'Legal Documents', 'briefcase'),
('financial', 'Financial Reports', 'dollar-sign'),
('member_resources', 'Member Resources', 'book-open'),
('forms', 'Forms & Templates', 'clipboard'),
('other', 'Other Documents', 'file');
5.2 Upload Permissions
Who can upload:
- Board members
- Administrators
Members cannot upload - they can only view documents shared with them.
5.3 Document Visibility (Per-Document)
Visibility Options:
| Level | Who Can View |
|---|---|
public |
Anyone (no login required) |
members |
All logged-in members |
board |
Board + Admin only |
admin |
Admin only |
Custom permissions can also specify specific member IDs for restricted access.
5.4 Document Schema
CREATE TABLE public.documents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Basic Info
title TEXT NOT NULL,
description TEXT,
category_id UUID REFERENCES public.document_categories(id),
-- File Info (Supabase Storage)
file_path TEXT NOT NULL, -- Storage path
file_name TEXT NOT NULL, -- Original filename
file_size INTEGER NOT NULL, -- Bytes
mime_type TEXT NOT NULL, -- 'application/pdf', etc.
-- Visibility
visibility TEXT NOT NULL DEFAULT 'members'
CHECK (visibility IN ('public', 'members', 'board', 'admin')),
-- Optional: Specific member access (for restricted docs)
allowed_member_ids UUID[], -- If set, only these members can view
-- Version tracking
version INTEGER DEFAULT 1,
replaces_document_id UUID REFERENCES public.documents(id),
-- Metadata
uploaded_by UUID NOT NULL REFERENCES public.members(id),
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Document access log (for audit)
CREATE TABLE public.document_access_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
document_id UUID NOT NULL REFERENCES public.documents(id) ON DELETE CASCADE,
accessed_by UUID REFERENCES public.members(id), -- null if public access
access_type TEXT NOT NULL CHECK (access_type IN ('view', 'download')),
ip_address TEXT,
accessed_at TIMESTAMPTZ DEFAULT NOW()
);
5.5 File Storage (Supabase Storage)
Bucket Configuration:
// Storage bucket: 'documents'
// Path structure: documents/{category}/{year}/{filename}
// Example paths:
// documents/meeting_minutes/2026/board-meeting-2026-01-15.pdf
// documents/governance/bylaws-v2.pdf
// documents/financial/2025/annual-report-2025.pdf
Upload Limits:
- Max file size: 50MB
- Allowed types: PDF, DOC, DOCX, XLS, XLSX, PPT, PPTX, TXT, JPG, PNG
5.6 Document UI Features
Document Library View:
- Filter by category
- Filter by visibility level
- Search by title/description
- Sort by date, name, category
- Grid or list view toggle
Document Card:
┌────────────────────────────────────────┐
│ 📄 [Category Icon] │
│ │
│ Board Meeting Minutes - January 2026 │
│ Meeting minutes from the monthly... │
│ │
│ 📅 Jan 15, 2026 | 📎 PDF | 1.2 MB │
│ │
│ [View] [Download] 👁️ Members │
└────────────────────────────────────────┘
Upload Form (Board/Admin):
- Title (required)
- Description (optional)
- Category (required, dropdown)
- Visibility (required)
- Custom access (optional, member multi-select)
- File upload (drag & drop)
5.7 Document Permissions (RLS)
-- RLS Policies for documents
ALTER TABLE public.documents ENABLE ROW LEVEL SECURITY;
-- Public documents viewable by anyone
CREATE POLICY "Public documents are viewable"
ON public.documents FOR SELECT
USING (visibility = 'public');
-- Member documents viewable by authenticated users
CREATE POLICY "Member documents viewable by members"
ON public.documents FOR SELECT
TO authenticated
USING (
visibility = 'members'
OR visibility = 'public'
OR (visibility = 'board' AND EXISTS (
SELECT 1 FROM public.members WHERE id = auth.uid() AND role IN ('board', 'admin')
))
OR (visibility = 'admin' AND EXISTS (
SELECT 1 FROM public.members WHERE id = auth.uid() AND role = 'admin'
))
OR (allowed_member_ids IS NOT NULL AND auth.uid() = ANY(allowed_member_ids))
);
-- Board/Admin can manage documents
CREATE POLICY "Board can upload documents"
ON public.documents FOR INSERT
TO authenticated
WITH CHECK (
EXISTS (SELECT 1 FROM public.members WHERE id = auth.uid() AND role IN ('board', 'admin'))
);
CREATE POLICY "Uploader or admin can update documents"
ON public.documents FOR UPDATE
TO authenticated
USING (
uploaded_by = auth.uid()
OR EXISTS (SELECT 1 FROM public.members WHERE id = auth.uid() AND role = 'admin')
);
CREATE POLICY "Admin can delete documents"
ON public.documents FOR DELETE
TO authenticated
USING (
EXISTS (SELECT 1 FROM public.members WHERE id = auth.uid() AND role = 'admin')
);
5.8 Version History
Document versioning:
- When replacing a document, create new record with
replaces_document_id - Previous versions remain accessible (archived)
- View version history for any document
-- Get version history for a document
SELECT d.*, m.first_name, m.last_name
FROM public.documents d
JOIN public.members m ON d.uploaded_by = m.id
WHERE d.id = :document_id
OR d.replaces_document_id = :document_id
OR d.id IN (
SELECT replaces_document_id FROM public.documents
WHERE id = :document_id
)
ORDER BY d.version DESC;
5.9 Meeting Minutes Special Handling
For meeting minutes category:
- Date field (meeting date)
- Attendees list (optional)
- Agenda reference (optional)
- Quick template for consistency
-- Optional meeting minutes metadata
ALTER TABLE public.documents ADD COLUMN meeting_date DATE;
ALTER TABLE public.documents ADD COLUMN meeting_attendees UUID[];
6. ADMIN SETTINGS SYSTEM (Detailed)
6.1 Settings Architecture Overview
Centralized configuration for all customizable aspects of the portal, accessible only to Admins via /admin/settings.
Settings Categories:
- Organization - Association branding and info
- Membership - Statuses, types, and pricing
- Dues - Payment settings and reminders
- Events - Event types and defaults
- Documents - Categories and storage
- Directory - Visibility controls
- Email - SMTP and template settings
- System - Technical settings
6.2 Settings Storage (Unified Table)
-- Flexible key-value settings with JSON support
CREATE TABLE public.app_settings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
category TEXT NOT NULL, -- 'organization', 'dues', 'email', etc.
setting_key TEXT NOT NULL, -- 'payment_iban', 'reminder_days', etc.
setting_value JSONB NOT NULL, -- Supports strings, numbers, arrays, objects
setting_type TEXT NOT NULL DEFAULT 'text' -- 'text', 'number', 'boolean', 'json', 'array'
CHECK (setting_type IN ('text', 'number', 'boolean', 'json', 'array')),
display_name TEXT NOT NULL, -- Human-readable label
description TEXT, -- Help text for admins
is_public BOOLEAN DEFAULT FALSE, -- If true, accessible without auth
updated_at TIMESTAMPTZ DEFAULT NOW(),
updated_by UUID REFERENCES public.members(id),
UNIQUE(category, setting_key)
);
-- Audit log for settings changes
CREATE TABLE public.settings_audit_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
setting_id UUID NOT NULL REFERENCES public.app_settings(id),
old_value JSONB,
new_value JSONB NOT NULL,
changed_by UUID NOT NULL REFERENCES public.members(id),
changed_at TIMESTAMPTZ DEFAULT NOW(),
change_reason TEXT
);
-- RLS: Only admins can read/write settings
ALTER TABLE public.app_settings ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Only admins can manage settings"
ON public.app_settings FOR ALL
TO authenticated
USING (
EXISTS (SELECT 1 FROM public.members WHERE id = auth.uid() AND role = 'admin')
OR is_public = TRUE
);
6.3 Default Settings (Seeded on First Run)
-- Organization Settings
INSERT INTO app_settings (category, setting_key, setting_value, setting_type, display_name, description, is_public) VALUES
('organization', 'association_name', '"Monaco USA"', 'text', 'Association Name', 'Official name of the association', true),
('organization', 'tagline', '"Americans in Monaco"', 'text', 'Tagline', 'Association tagline shown on login', true),
('organization', 'contact_email', '"contact@monacousa.org"', 'text', 'Contact Email', 'Public contact email address', true),
('organization', 'address', '"Monaco"', 'text', 'Address', 'Association physical address', true),
('organization', 'logo_url', '"/logo.png"', 'text', 'Logo URL', 'Path to association logo', true),
('organization', 'primary_color', '"#dc2626"', 'text', 'Primary Color', 'Brand primary color (hex)', true),
-- Dues Settings
('dues', 'payment_iban', '"MC58 1756 9000 0104 0050 1001 860"', 'text', 'Payment IBAN', 'Bank IBAN for dues payment', false),
('dues', 'payment_account_holder', '"ASSOCIATION MONACO USA"', 'text', 'Account Holder', 'Bank account holder name', false),
('dues', 'payment_bank_name', '"Credit Foncier de Monaco"', 'text', 'Bank Name', 'Name of the bank', false),
('dues', 'payment_instructions', '"Please include your Member ID (MUSA-XXXX) in the reference"', 'text', 'Payment Instructions', 'Instructions shown to members', false),
('dues', 'reminder_days_before', '[30, 7, 1]', 'array', 'Reminder Days', 'Days before due date to send reminders', false),
('dues', 'grace_period_days', '30', 'number', 'Grace Period', 'Days after due date before auto-inactive', false),
('dues', 'overdue_reminder_interval', '14', 'number', 'Overdue Reminder Interval', 'Days between overdue reminder emails', false),
('dues', 'auto_inactive_enabled', 'true', 'boolean', 'Auto Inactive', 'Automatically set members inactive after grace period', false),
-- Event Settings
('events', 'default_max_guests', '2', 'number', 'Default Max Guests', 'Default maximum guests per RSVP', false),
('events', 'reminder_hours_before', '[24, 1]', 'array', 'Event Reminder Hours', 'Hours before event to send reminders', false),
('events', 'allow_public_rsvp', 'true', 'boolean', 'Allow Public RSVP', 'Allow non-members to RSVP to public events', false),
('events', 'auto_close_rsvp_hours', '0', 'number', 'Auto Close RSVP', 'Hours before event to close RSVP (0 = never)', false),
-- Directory Settings
('directory', 'member_visible_fields', '["first_name", "last_name", "avatar_url", "nationality", "member_since"]', 'array', 'Member Visible Fields', 'Fields visible to regular members', false),
('directory', 'board_visible_fields', '["first_name", "last_name", "avatar_url", "nationality", "email", "phone", "address", "date_of_birth", "member_since", "membership_status"]', 'array', 'Board Visible Fields', 'Fields visible to board members', false),
('directory', 'show_membership_status', 'false', 'boolean', 'Show Status to Members', 'Show membership status in directory for regular members', false),
-- System Settings
('system', 'maintenance_mode', 'false', 'boolean', 'Maintenance Mode', 'Put the portal in maintenance mode', false),
('system', 'maintenance_message', '"The portal is currently undergoing maintenance. Please check back soon."', 'text', 'Maintenance Message', 'Message shown during maintenance', false),
('system', 'session_timeout_hours', '168', 'number', 'Session Timeout', 'Hours until session expires (default: 7 days)', false),
('system', 'max_upload_size_mb', '50', 'number', 'Max Upload Size', 'Maximum file upload size in MB', false),
('system', 'allowed_file_types', '["pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "txt", "jpg", "jpeg", "png", "webp"]', 'array', 'Allowed File Types', 'Allowed file extensions for uploads', false);
6.4 Settings UI Layout
Navigation Tabs:
┌──────────────────────────────────────────────────────────────────┐
│ ⚙️ Settings │
├──────────────────────────────────────────────────────────────────┤
│ [Organization] [Membership] [Dues] [Events] [Documents] │
│ [Directory] [Email] [System] │
├──────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ Organization Settings │ │
│ │ ───────────────────────────────────────────────────────── │ │
│ │ │ │
│ │ Association Name │ │
│ │ ┌──────────────────────────────────────────────────────┐ │ │
│ │ │ Monaco USA │ │ │
│ │ └──────────────────────────────────────────────────────┘ │ │
│ │ Official name of the association │ │
│ │ │ │
│ │ Tagline │ │
│ │ ┌──────────────────────────────────────────────────────┐ │ │
│ │ │ Americans in Monaco │ │ │
│ │ └──────────────────────────────────────────────────────┘ │ │
│ │ Association tagline shown on login │ │
│ │ │ │
│ │ Primary Color │ │
│ │ ┌────────┐ ┌──────────────────────────────────────────┐ │ │
│ │ │ 🎨 │ │ #dc2626 │ │ │
│ │ └────────┘ └──────────────────────────────────────────┘ │ │
│ │ │ │
│ │ [Save Changes] │ │
│ └─────────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────┘
6.5 Membership Settings Tab
Manages configurable membership statuses and types:
┌──────────────────────────────────────────────────────────────────┐
│ Membership Settings │
├──────────────────────────────────────────────────────────────────┤
│ │
│ MEMBERSHIP STATUSES │
│ ───────────────────────────────────────────────────────────── │
│ │
│ ┌───────────┬─────────────┬──────────┬────────────┬──────────┐ │
│ │ Name │ Display │ Color │ Is Default │ Actions │ │
│ ├───────────┼─────────────┼──────────┼────────────┼──────────┤ │
│ │ pending │ Pending │ 🟡 Yellow│ ✓ │ ✏️ 🗑️ │ │
│ │ active │ Active │ 🟢 Green │ │ ✏️ 🗑️ │ │
│ │ inactive │ Inactive │ ⚪ Gray │ │ ✏️ 🗑️ │ │
│ │ expired │ Expired │ 🔴 Red │ │ ✏️ 🗑️ │ │
│ └───────────┴─────────────┴──────────┴────────────┴──────────┘ │
│ │
│ [+ Add Status] │
│ │
│ ───────────────────────────────────────────────────────────── │
│ │
│ MEMBERSHIP TYPES │
│ ───────────────────────────────────────────────────────────── │
│ │
│ ┌───────────┬───────────────┬──────────┬────────────┬────────┐ │
│ │ Name │ Display │ Annual € │ Is Default │Actions │ │
│ ├───────────┼───────────────┼──────────┼────────────┼────────┤ │
│ │ regular │ Regular │ €50.00 │ ✓ │ ✏️ 🗑️ │ │
│ │ student │ Student │ €25.00 │ │ ✏️ 🗑️ │ │
│ │ senior │ Senior (65+) │ €35.00 │ │ ✏️ 🗑️ │ │
│ │ family │ Family │ €75.00 │ │ ✏️ 🗑️ │ │
│ │ honorary │ Honorary │ €0.00 │ │ ✏️ 🗑️ │ │
│ └───────────┴───────────────┴──────────┴────────────┴────────┘ │
│ │
│ [+ Add Membership Type] │
│ │
└──────────────────────────────────────────────────────────────────┘
6.6 Event Types Settings
Admin can manage event types with colors and icons:
┌──────────────────────────────────────────────────────────────────┐
│ Event Types │
├──────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────┬───────────────┬────────────┬────────┬────────┐ │
│ │ Name │ Display │ Color │ Icon │Actions │ │
│ ├─────────────┼───────────────┼────────────┼────────┼────────┤ │
│ │ social │ Social Event │ 🟢 #10b981 │ 🎉 │ ✏️ 🗑️ │ │
│ │ meeting │ Meeting │ 🔵 #6366f1 │ 👥 │ ✏️ 🗑️ │ │
│ │ fundraiser │ Fundraiser │ 🟠 #f59e0b │ 💝 │ ✏️ 🗑️ │ │
│ │ workshop │ Workshop │ 🟣 #8b5cf6 │ 🎓 │ ✏️ 🗑️ │ │
│ │ gala │ Gala/Formal │ 🌸 #ec4899 │ ✨ │ ✏️ 🗑️ │ │
│ │ other │ Other │ ⚫ #6b7280 │ 📅 │ ✏️ 🗑️ │ │
│ └─────────────┴───────────────┴────────────┴────────┴────────┘ │
│ │
│ [+ Add Event Type] │
│ │
└──────────────────────────────────────────────────────────────────┘
6.7 Document Categories Settings
┌──────────────────────────────────────────────────────────────────┐
│ Document Categories │
├──────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────┬─────────────────────┬────────┬────────────┐ │
│ │ Name │ Display │ Icon │ Actions │ │
│ ├─────────────────┼─────────────────────┼────────┼────────────┤ │
│ │ meeting_minutes │ Meeting Minutes │ 📄 │ ✏️ 🗑️ │ │
│ │ governance │ Governance & Bylaws │ ⚖️ │ ✏️ 🗑️ │ │
│ │ legal │ Legal Documents │ 💼 │ ✏️ 🗑️ │ │
│ │ financial │ Financial Reports │ 💰 │ ✏️ 🗑️ │ │
│ │ member_resources│ Member Resources │ 📚 │ ✏️ 🗑️ │ │
│ │ forms │ Forms & Templates │ 📋 │ ✏️ 🗑️ │ │
│ │ other │ Other Documents │ 📁 │ ✏️ 🗑️ │ │
│ └─────────────────┴─────────────────────┴────────┴────────────┘ │
│ │
│ [+ Add Category] │
│ │
└──────────────────────────────────────────────────────────────────┘
6.8 Directory Visibility Settings
Admin controls what fields are visible to different roles:
┌──────────────────────────────────────────────────────────────────┐
│ Directory Visibility │
├──────────────────────────────────────────────────────────────────┤
│ │
│ Configure which member fields are visible in the directory. │
│ Admin always sees all fields. │
│ │
│ ┌─────────────────┬──────────────────┬──────────────────┐ │
│ │ Field │ Visible to │ Visible to │ │
│ │ │ Members │ Board │ │
│ ├─────────────────┼──────────────────┼──────────────────┤ │
│ │ First Name │ ☑️ Always shown │ ☑️ Always shown │ │
│ │ Last Name │ ☑️ Always shown │ ☑️ Always shown │ │
│ │ Profile Photo │ ☑️ │ ☑️ │ │
│ │ Nationality │ ☑️ │ ☑️ │ │
│ │ Email │ ☐ │ ☑️ │ │
│ │ Phone │ ☐ │ ☑️ │ │
│ │ Address │ ☐ │ ☑️ │ │
│ │ Date of Birth │ ☐ │ ☑️ │ │
│ │ Member Since │ ☑️ │ ☑️ │ │
│ │ Status │ ☐ │ ☑️ │ │
│ │ Membership Type │ ☐ │ ☑️ │ │
│ └─────────────────┴──────────────────┴──────────────────┘ │
│ │
│ [Save Visibility Settings] │
│ │
└──────────────────────────────────────────────────────────────────┘
6.9 System Settings Tab
┌──────────────────────────────────────────────────────────────────┐
│ System Settings │
├──────────────────────────────────────────────────────────────────┤
│ │
│ MAINTENANCE │
│ ───────────────────────────────────────────────────────────── │
│ │
│ ☐ Enable Maintenance Mode │
│ │
│ Maintenance Message: │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ The portal is currently undergoing maintenance. │ │
│ │ Please check back soon. │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │
│ ───────────────────────────────────────────────────────────── │
│ │
│ SECURITY │
│ ───────────────────────────────────────────────────────────── │
│ │
│ Session Timeout (hours): │
│ ┌────────────┐ │
│ │ 168 │ (7 days) │
│ └────────────┘ │
│ │
│ ───────────────────────────────────────────────────────────── │
│ │
│ FILE UPLOADS │
│ ───────────────────────────────────────────────────────────── │
│ │
│ Max Upload Size (MB): │
│ ┌────────────┐ │
│ │ 50 │ │
│ └────────────┘ │
│ │
│ Allowed File Types: │
│ [PDF] [DOC] [DOCX] [XLS] [XLSX] [PPT] [PPTX] │
│ [TXT] [JPG] [PNG] [WEBP] [+ Add Type] │
│ │
│ [Save System Settings] │
│ │
└──────────────────────────────────────────────────────────────────┘
6.10 Settings Access Pattern
// src/lib/server/settings.ts
// Get a single setting with type safety
export async function getSetting<T>(
supabase: SupabaseClient,
category: string,
key: string,
defaultValue: T
): Promise<T> {
const { data } = await supabase
.from('app_settings')
.select('setting_value')
.eq('category', category)
.eq('setting_key', key)
.single();
return data?.setting_value ?? defaultValue;
}
// Get all settings for a category
export async function getCategorySettings(
supabase: SupabaseClient,
category: string
): Promise<Record<string, any>> {
const { data } = await supabase
.from('app_settings')
.select('setting_key, setting_value')
.eq('category', category);
return Object.fromEntries(
(data ?? []).map(s => [s.setting_key, s.setting_value])
);
}
// Update a setting (admin only)
export async function updateSetting(
supabase: SupabaseClient,
category: string,
key: string,
value: any,
userId: string
): Promise<void> {
await supabase
.from('app_settings')
.update({
setting_value: value,
updated_at: new Date().toISOString(),
updated_by: userId
})
.eq('category', category)
.eq('setting_key', key);
}
6.11 Settings Permissions
| Action | Member | Board | Admin |
|---|---|---|---|
| View public settings | ✓ | ✓ | ✓ |
| View all settings | - | - | ✓ |
| Edit settings | - | - | ✓ |
| Manage statuses | - | - | ✓ |
| Manage membership types | - | - | ✓ |
| Manage event types | - | - | ✓ |
| Manage document categories | - | - | ✓ |
| View settings audit log | - | - | ✓ |
7. EMAIL SYSTEM (Detailed)
7.1 Email Architecture
Provider: Supabase Edge Functions + external SMTP (Resend, SendGrid, or Mailgun)
Why external SMTP:
- Supabase built-in email is limited to auth emails only
- External SMTP provides better deliverability, tracking, and templates
- Resend recommended for simplicity and modern API
7.2 Email Provider Configuration
-- Email settings (stored in app_settings)
INSERT INTO app_settings (category, setting_key, setting_value, setting_type, display_name, description) VALUES
('email', 'provider', '"resend"', 'text', 'Email Provider', 'Email service provider (resend, sendgrid, mailgun)'),
('email', 'api_key', '""', 'text', 'API Key', 'Email provider API key (stored securely)'),
('email', 'from_address', '"noreply@monacousa.org"', 'text', 'From Address', 'Default sender email address'),
('email', 'from_name', '"Monaco USA"', 'text', 'From Name', 'Default sender name'),
('email', 'reply_to', '"contact@monacousa.org"', 'text', 'Reply-To Address', 'Reply-to email address'),
('email', 'enable_tracking', 'true', 'boolean', 'Enable Tracking', 'Track email opens and clicks'),
('email', 'batch_size', '50', 'number', 'Batch Size', 'Max emails per batch send'),
('email', 'rate_limit_per_hour', '100', 'number', 'Rate Limit', 'Maximum emails per hour');
7.3 Email Types & Triggers
| Email Type | Trigger | Recipients | Automated |
|---|---|---|---|
welcome |
New signup verified | New member | Yes |
email_verification |
Signup | New member | Yes (Supabase) |
password_reset |
Password reset request | Member | Yes (Supabase) |
dues_reminder |
X days before due | Member | Yes (cron) |
dues_due_today |
Due date | Member | Yes (cron) |
dues_overdue |
Every X days overdue | Member | Yes (cron) |
dues_lapsed |
Grace period ends | Member | Yes (cron) |
dues_received |
Payment recorded | Member | Yes |
event_created |
New event published | All/visibility | Optional |
event_reminder |
X hours before event | RSVP'd members | Yes (cron) |
event_updated |
Event details changed | RSVP'd members | Yes |
event_cancelled |
Event cancelled | RSVP'd members | Yes |
rsvp_confirmation |
RSVP submitted | Member | Yes |
waitlist_promoted |
Spot opens up | Waitlisted member | Yes |
member_invite |
Admin invites member | Invitee | Manual |
broadcast |
Admin sends message | Selected members | Manual |
7.4 Email Templates Schema
CREATE TABLE public.email_templates (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Template identification
template_key TEXT UNIQUE NOT NULL, -- 'dues_reminder', 'welcome', etc.
template_name TEXT NOT NULL, -- 'Dues Reminder Email'
category TEXT NOT NULL, -- 'dues', 'events', 'system'
-- Template content
subject TEXT NOT NULL, -- Subject line with {{variables}}
body_html TEXT NOT NULL, -- HTML body with {{variables}}
body_text TEXT, -- Plain text fallback
-- Settings
is_active BOOLEAN DEFAULT TRUE,
is_system BOOLEAN DEFAULT FALSE, -- System templates can't be deleted
-- Metadata
variables_schema JSONB, -- Available variables documentation
preview_data JSONB, -- Sample data for preview
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
updated_by UUID REFERENCES public.members(id)
);
-- Default email templates
INSERT INTO email_templates (template_key, template_name, category, subject, body_html, is_system, variables_schema) VALUES
-- Welcome Email
('welcome', 'Welcome Email', 'system',
'Welcome to Monaco USA, {{member_name}}!',
'<!DOCTYPE html>
<html>
<head><style>body{font-family:Arial,sans-serif;}</style></head>
<body>
<div style="max-width:600px;margin:0 auto;padding:20px;">
<img src="{{logo_url}}" alt="Monaco USA" style="max-width:150px;">
<h1>Welcome to Monaco USA!</h1>
<p>Dear {{member_name}},</p>
<p>Thank you for joining Monaco USA! Your Member ID is <strong>{{member_id}}</strong>.</p>
<p>To complete your membership, please pay your annual dues of <strong>€{{dues_amount}}</strong>.</p>
<h3>Payment Details:</h3>
<ul>
<li>Bank: {{bank_name}}</li>
<li>IBAN: {{iban}}</li>
<li>Account Holder: {{account_holder}}</li>
<li>Reference: {{member_id}}</li>
</ul>
<p><a href="{{portal_link}}" style="background:#dc2626;color:white;padding:10px 20px;text-decoration:none;border-radius:5px;">Access Your Portal</a></p>
<p>Best regards,<br>Monaco USA Team</p>
</div>
</body>
</html>',
TRUE,
'{"member_name":"string","member_id":"string","dues_amount":"number","bank_name":"string","iban":"string","account_holder":"string","portal_link":"string","logo_url":"string"}'
),
-- Dues Reminder
('dues_reminder', 'Dues Reminder', 'dues',
'Your Monaco USA dues are due in {{days_until_due}} days',
'<!DOCTYPE html>
<html>
<body style="font-family:Arial,sans-serif;">
<div style="max-width:600px;margin:0 auto;padding:20px;">
<h1>Dues Reminder</h1>
<p>Dear {{member_name}},</p>
<p>This is a friendly reminder that your Monaco USA membership dues of <strong>€{{dues_amount}}</strong> are due on <strong>{{due_date}}</strong> ({{days_until_due}} days from now).</p>
<h3>Payment Details:</h3>
<ul>
<li>IBAN: {{iban}}</li>
<li>Account Holder: {{account_holder}}</li>
<li>Reference: {{member_id}}</li>
</ul>
<p><a href="{{portal_link}}/payments" style="background:#dc2626;color:white;padding:10px 20px;text-decoration:none;border-radius:5px;">View Payment Details</a></p>
<p>Thank you for being a valued member!</p>
</div>
</body>
</html>',
TRUE,
'{"member_name":"string","member_id":"string","dues_amount":"number","due_date":"date","days_until_due":"number","iban":"string","account_holder":"string","portal_link":"string"}'
),
-- Dues Overdue
('dues_overdue', 'Dues Overdue Notice', 'dues',
'OVERDUE: Your Monaco USA dues are {{days_overdue}} days past due',
'<!DOCTYPE html>
<html>
<body style="font-family:Arial,sans-serif;">
<div style="max-width:600px;margin:0 auto;padding:20px;">
<h1 style="color:#dc2626;">Payment Overdue</h1>
<p>Dear {{member_name}},</p>
<p>Your Monaco USA membership dues of <strong>€{{dues_amount}}</strong> are now <strong>{{days_overdue}} days overdue</strong>.</p>
<p>Please make your payment as soon as possible to maintain your membership benefits.</p>
{{#if grace_period_remaining}}
<p><strong>Note:</strong> You have {{grace_period_remaining}} days remaining in your grace period before your membership is set to inactive.</p>
{{/if}}
<h3>Payment Details:</h3>
<ul>
<li>IBAN: {{iban}}</li>
<li>Account Holder: {{account_holder}}</li>
<li>Reference: {{member_id}}</li>
</ul>
<p><a href="{{portal_link}}/payments" style="background:#dc2626;color:white;padding:10px 20px;text-decoration:none;border-radius:5px;">Pay Now</a></p>
</div>
</body>
</html>',
TRUE,
'{"member_name":"string","member_id":"string","dues_amount":"number","days_overdue":"number","grace_period_remaining":"number","iban":"string","account_holder":"string","portal_link":"string"}'
),
-- Dues Received
('dues_received', 'Payment Confirmation', 'dues',
'Thank you! Your Monaco USA dues payment has been received',
'<!DOCTYPE html>
<html>
<body style="font-family:Arial,sans-serif;">
<div style="max-width:600px;margin:0 auto;padding:20px;">
<h1 style="color:#10b981;">Payment Received!</h1>
<p>Dear {{member_name}},</p>
<p>Thank you! We have received your membership dues payment.</p>
<h3>Payment Details:</h3>
<ul>
<li>Amount: €{{amount_paid}}</li>
<li>Payment Date: {{payment_date}}</li>
<li>Next Due Date: {{next_due_date}}</li>
<li>Reference: {{payment_reference}}</li>
</ul>
<p>Your membership is now active until {{next_due_date}}.</p>
<p><a href="{{portal_link}}/payments" style="background:#10b981;color:white;padding:10px 20px;text-decoration:none;border-radius:5px;">View Payment History</a></p>
</div>
</body>
</html>',
TRUE,
'{"member_name":"string","amount_paid":"number","payment_date":"date","next_due_date":"date","payment_reference":"string","portal_link":"string"}'
),
-- Event RSVP Confirmation
('rsvp_confirmation', 'RSVP Confirmation', 'events',
'You''re registered: {{event_title}}',
'<!DOCTYPE html>
<html>
<body style="font-family:Arial,sans-serif;">
<div style="max-width:600px;margin:0 auto;padding:20px;">
<h1>You''re Registered!</h1>
<p>Dear {{member_name}},</p>
<p>Your RSVP for <strong>{{event_title}}</strong> has been confirmed.</p>
<h3>Event Details:</h3>
<ul>
<li><strong>Date:</strong> {{event_date}}</li>
<li><strong>Time:</strong> {{event_time}}</li>
<li><strong>Location:</strong> {{event_location}}</li>
{{#if guest_count}}<li><strong>Additional Guests:</strong> {{guest_count}}</li>{{/if}}
</ul>
{{#if is_paid}}
<h3>Payment Required:</h3>
<p>Total: €{{total_amount}}</p>
<ul>
<li>IBAN: {{iban}}</li>
<li>Reference: {{payment_reference}}</li>
</ul>
{{/if}}
<p><a href="{{portal_link}}/events/{{event_id}}" style="background:#dc2626;color:white;padding:10px 20px;text-decoration:none;border-radius:5px;">View Event</a></p>
</div>
</body>
</html>',
TRUE,
'{"member_name":"string","event_title":"string","event_date":"date","event_time":"string","event_location":"string","guest_count":"number","is_paid":"boolean","total_amount":"number","iban":"string","payment_reference":"string","event_id":"string","portal_link":"string"}'
),
-- Event Reminder
('event_reminder', 'Event Reminder', 'events',
'Reminder: {{event_title}} is {{time_until_event}}',
'<!DOCTYPE html>
<html>
<body style="font-family:Arial,sans-serif;">
<div style="max-width:600px;margin:0 auto;padding:20px;">
<h1>Event Reminder</h1>
<p>Dear {{member_name}},</p>
<p>This is a reminder that <strong>{{event_title}}</strong> is {{time_until_event}}.</p>
<h3>Event Details:</h3>
<ul>
<li><strong>Date:</strong> {{event_date}}</li>
<li><strong>Time:</strong> {{event_time}}</li>
<li><strong>Location:</strong> {{event_location}}</li>
</ul>
{{#if event_description}}
<p>{{event_description}}</p>
{{/if}}
<p>We look forward to seeing you there!</p>
</div>
</body>
</html>',
TRUE,
'{"member_name":"string","event_title":"string","event_date":"date","event_time":"string","event_location":"string","event_description":"string","time_until_event":"string"}'
);
7.5 Email Logging Schema
-- Enhanced email logs with tracking
CREATE TABLE public.email_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Recipients
recipient_id UUID REFERENCES public.members(id),
recipient_email TEXT NOT NULL,
recipient_name TEXT,
-- Email details
template_key TEXT REFERENCES public.email_templates(template_key),
subject TEXT NOT NULL,
email_type TEXT NOT NULL,
-- Status tracking
status TEXT NOT NULL DEFAULT 'queued'
CHECK (status IN ('queued', 'sent', 'delivered', 'opened', 'clicked', 'bounced', 'failed')),
-- Provider data
provider TEXT, -- 'resend', 'sendgrid', etc.
provider_message_id TEXT, -- External message ID for tracking
-- Engagement tracking
opened_at TIMESTAMPTZ,
clicked_at TIMESTAMPTZ,
-- Error handling
error_message TEXT,
retry_count INTEGER DEFAULT 0,
-- Metadata
template_variables JSONB, -- Variables used in template
sent_by UUID REFERENCES public.members(id), -- For manual sends
-- Timestamps
created_at TIMESTAMPTZ DEFAULT NOW(),
sent_at TIMESTAMPTZ,
delivered_at TIMESTAMPTZ
);
-- Index for common queries
CREATE INDEX idx_email_logs_recipient ON public.email_logs(recipient_id);
CREATE INDEX idx_email_logs_status ON public.email_logs(status);
CREATE INDEX idx_email_logs_type ON public.email_logs(email_type);
CREATE INDEX idx_email_logs_created ON public.email_logs(created_at DESC);
7.6 Automated Email Scheduler (Supabase Edge Function)
// supabase/functions/email-scheduler/index.ts
import { createClient } from '@supabase/supabase-js';
import { Resend } from 'resend';
// Runs daily via pg_cron
Deno.serve(async (req) => {
const supabase = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
);
const today = new Date();
today.setHours(0, 0, 0, 0);
// 1. Get settings
const settings = await getSettings(supabase);
const reminderDays = settings.reminder_days_before as number[];
const gracePeriod = settings.grace_period_days as number;
// 2. Find members needing reminders
const { data: membersWithDues } = await supabase
.from('members_with_dues')
.select('*')
.in('dues_status', ['current', 'due_soon', 'overdue']);
for (const member of membersWithDues || []) {
const dueDate = new Date(member.current_due_date);
const daysUntil = Math.ceil((dueDate.getTime() - today.getTime()) / (1000 * 60 * 60 * 24));
const daysOverdue = member.days_overdue || 0;
// Check if we need to send a reminder
if (daysUntil > 0 && reminderDays.includes(daysUntil)) {
// Send upcoming reminder
await sendEmail(supabase, 'dues_reminder', member, {
days_until_due: daysUntil
});
} else if (daysUntil === 0) {
// Due today
await sendEmail(supabase, 'dues_due_today', member, {});
} else if (daysOverdue > 0 && daysOverdue <= gracePeriod) {
// Overdue but in grace period
if (daysOverdue % settings.overdue_reminder_interval === 0) {
await sendEmail(supabase, 'dues_overdue', member, {
days_overdue: daysOverdue,
grace_period_remaining: gracePeriod - daysOverdue
});
}
} else if (daysOverdue === gracePeriod + 1) {
// Grace period just ended
await sendEmail(supabase, 'dues_lapsed', member, {});
}
}
// 3. Send event reminders
await sendEventReminders(supabase, settings);
return new Response(JSON.stringify({ success: true }), {
headers: { 'Content-Type': 'application/json' }
});
});
async function sendEmail(
supabase: any,
templateKey: string,
member: any,
extraVariables: Record<string, any>
) {
// Get template
const { data: template } = await supabase
.from('email_templates')
.select('*')
.eq('template_key', templateKey)
.eq('is_active', true)
.single();
if (!template) return;
// Build variables
const variables = {
member_name: `${member.first_name} ${member.last_name}`,
member_id: member.member_id,
dues_amount: member.annual_dues || 50,
due_date: member.current_due_date,
portal_link: Deno.env.get('PORTAL_URL'),
...extraVariables
};
// Add payment settings
const settings = await getSettings(supabase);
variables.iban = settings.payment_iban;
variables.account_holder = settings.payment_account_holder;
variables.bank_name = settings.payment_bank_name;
// Render template
const subject = renderTemplate(template.subject, variables);
const html = renderTemplate(template.body_html, variables);
// Send via provider
const resend = new Resend(Deno.env.get('RESEND_API_KEY'));
const result = await resend.emails.send({
from: `${settings.from_name} <${settings.from_address}>`,
to: member.email,
subject,
html
});
// Log email
await supabase.from('email_logs').insert({
recipient_id: member.id,
recipient_email: member.email,
recipient_name: variables.member_name,
template_key: templateKey,
subject,
email_type: templateKey,
status: result.error ? 'failed' : 'sent',
provider: 'resend',
provider_message_id: result.data?.id,
error_message: result.error?.message,
template_variables: variables,
sent_at: new Date().toISOString()
});
}
7.7 Email Settings UI (Admin)
┌──────────────────────────────────────────────────────────────────┐
│ Email Settings │
├──────────────────────────────────────────────────────────────────┤
│ │
│ PROVIDER CONFIGURATION │
│ ───────────────────────────────────────────────────────────── │
│ │
│ Email Provider: │
│ ┌────────────────────────────────────────┐ │
│ │ Resend ▼ │ │
│ └────────────────────────────────────────┘ │
│ │
│ API Key: │
│ ┌────────────────────────────────────────┐ │
│ │ re_••••••••••••••• │ [Test Connection] │
│ └────────────────────────────────────────┘ │
│ │
│ From Address: │
│ ┌────────────────────────────────────────┐ │
│ │ noreply@monacousa.org │ │
│ └────────────────────────────────────────┘ │
│ │
│ From Name: │
│ ┌────────────────────────────────────────┐ │
│ │ Monaco USA │ │
│ └────────────────────────────────────────┘ │
│ │
│ Reply-To: │
│ ┌────────────────────────────────────────┐ │
│ │ contact@monacousa.org │ │
│ └────────────────────────────────────────┘ │
│ │
│ ───────────────────────────────────────────────────────────── │
│ │
│ TRACKING │
│ ───────────────────────────────────────────────────────────── │
│ │
│ ☑️ Enable open tracking │
│ ☑️ Enable click tracking │
│ │
│ [Save Email Settings] │
│ │
└──────────────────────────────────────────────────────────────────┘
7.8 Email Templates Editor (Admin)
┌──────────────────────────────────────────────────────────────────┐
│ Email Templates │
├──────────────────────────────────────────────────────────────────┤
│ │
│ ┌───────────────────┬────────────────┬─────────┬──────────────┐ │
│ │ Template │ Category │ Active │ Actions │ │
│ ├───────────────────┼────────────────┼─────────┼──────────────┤ │
│ │ Welcome Email │ System │ ✓ │ ✏️ 👁️ 📧 │ │
│ │ Dues Reminder │ Dues │ ✓ │ ✏️ 👁️ 📧 │ │
│ │ Dues Due Today │ Dues │ ✓ │ ✏️ 👁️ 📧 │ │
│ │ Dues Overdue │ Dues │ ✓ │ ✏️ 👁️ 📧 │ │
│ │ Dues Lapsed │ Dues │ ✓ │ ✏️ 👁️ 📧 │ │
│ │ Dues Received │ Dues │ ✓ │ ✏️ 👁️ 📧 │ │
│ │ RSVP Confirmation │ Events │ ✓ │ ✏️ 👁️ 📧 │ │
│ │ Event Reminder │ Events │ ✓ │ ✏️ 👁️ 📧 │ │
│ │ Event Cancelled │ Events │ ✓ │ ✏️ 👁️ 📧 │ │
│ │ Waitlist Promoted │ Events │ ✓ │ ✏️ 👁️ 📧 │ │
│ └───────────────────┴────────────────┴─────────┴──────────────┘ │
│ │
│ Legend: ✏️ Edit | 👁️ Preview | 📧 Send Test │
│ │
│ [+ Create Custom Template] │
│ │
└──────────────────────────────────────────────────────────────────┘
7.9 Template Editor View
┌──────────────────────────────────────────────────────────────────┐
│ Edit Template: Dues Reminder │
├──────────────────────────────────────────────────────────────────┤
│ │
│ Template Name: │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ Dues Reminder │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │
│ Subject Line: │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ Your Monaco USA dues are due in {{days_until_due}} days │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │
│ ┌──────────────────────┬───────────────────────────────────┐ │
│ │ HTML Editor │ Preview │ │
│ ├──────────────────────┼───────────────────────────────────┤ │
│ │ <h1>Dues Reminder</h1│ │ │
│ │ <p>Dear {{member_nam │ ┌─────────────────────────────┐ │ │
│ │ e}},</p> │ │ Dues Reminder │ │ │
│ │ <p>This is a friendl │ │ │ │ │
│ │ y reminder that your │ │ Dear John Doe, │ │ │
│ │ Monaco USA membershi │ │ │ │ │
│ │ p dues...</p> │ │ This is a friendly │ │ │
│ │ ... │ │ reminder that your Monaco │ │ │
│ │ │ │ USA membership dues of │ │ │
│ │ │ │ €50.00 are due on... │ │ │
│ │ │ └─────────────────────────────┘ │ │
│ └──────────────────────┴───────────────────────────────────┘ │
│ │
│ AVAILABLE VARIABLES: │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ {{member_name}} {{member_id}} {{dues_amount}} {{due_date}}│ │
│ │ {{days_until_due}} {{iban}} {{account_holder}} │ │
│ │ {{portal_link}} │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │
│ [Cancel] [Send Test Email] [Save Changes] │
│ │
└──────────────────────────────────────────────────────────────────┘
7.10 Email Logs View (Admin)
┌──────────────────────────────────────────────────────────────────┐
│ Email Logs │
├──────────────────────────────────────────────────────────────────┤
│ │
│ Filter: [All Types ▼] [All Status ▼] [Last 30 days ▼] [Search] │
│ │
│ ┌────────┬────────────────┬──────────────┬────────┬──────────┐ │
│ │ Date │ Recipient │ Subject │ Type │ Status │ │
│ ├────────┼────────────────┼──────────────┼────────┼──────────┤ │
│ │ Jan 9 │ john@email.com │ Your Monaco │ dues_ │ 📬 Opened │ │
│ │ 14:30 │ John Doe │ USA dues... │ remind │ │ │
│ ├────────┼────────────────┼──────────────┼────────┼──────────┤ │
│ │ Jan 9 │ jane@email.com │ You're regis │ rsvp_ │ ✅ Sent │ │
│ │ 10:15 │ Jane Smith │ tered: Gala │ conf │ │ │
│ ├────────┼────────────────┼──────────────┼────────┼──────────┤ │
│ │ Jan 8 │ bob@email.com │ OVERDUE: You │ dues_ │ 🔴 Bounce │ │
│ │ 09:00 │ Bob Wilson │ r Monaco... │ overdu │ │ │
│ └────────┴────────────────┴──────────────┴────────┴──────────┘ │
│ │
│ Status Legend: │
│ ✅ Sent | 📬 Opened | 🔗 Clicked | 🔴 Bounced | ❌ Failed │
│ │
│ Stats: 156 sent this month | 78% open rate | 2 bounces │
│ │
└──────────────────────────────────────────────────────────────────┘
7.11 Manual Broadcast Feature (Admin)
Admin can send broadcast emails to selected members:
┌──────────────────────────────────────────────────────────────────┐
│ Send Broadcast Email │
├──────────────────────────────────────────────────────────────────┤
│ │
│ Recipients: │
│ ○ All active members (45) │
│ ○ All members (52) │
│ ○ Board members only (5) │
│ ● Select specific members │
│ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ 🔍 Search members... │ │
│ ├──────────────────────────────────────────────────────────┤ │
│ │ ☑️ John Doe (john@email.com) │ │
│ │ ☑️ Jane Smith (jane@email.com) │ │
│ │ ☐ Bob Wilson (bob@email.com) │ │
│ │ ... │ │
│ └──────────────────────────────────────────────────────────┘ │
│ Selected: 2 members │
│ │
│ Subject: │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ Important Update from Monaco USA │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │
│ Message: │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ [Rich text editor with formatting options] │ │
│ │ │ │
│ │ Dear {{member_name}}, │ │
│ │ │ │
│ │ We wanted to inform you about... │ │
│ │ │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │
│ [Preview] [Send Test to Myself] [Send to 2 Recipients] │
│ │
└──────────────────────────────────────────────────────────────────┘
7.12 Email Cron Jobs (pg_cron in Supabase)
-- Enable pg_cron extension
CREATE EXTENSION IF NOT EXISTS pg_cron;
-- Schedule daily email checks (runs at 9 AM Monaco time)
SELECT cron.schedule(
'daily-email-scheduler',
'0 9 * * *', -- Every day at 9:00 AM
$$
SELECT net.http_post(
url := 'https://your-project.supabase.co/functions/v1/email-scheduler',
headers := '{"Authorization": "Bearer ' || current_setting('app.service_role_key') || '"}'::jsonb,
body := '{}'::jsonb
);
$$
);
-- Schedule event reminders (runs every hour)
SELECT cron.schedule(
'hourly-event-reminders',
'0 * * * *', -- Every hour
$$
SELECT net.http_post(
url := 'https://your-project.supabase.co/functions/v1/event-reminders',
headers := '{"Authorization": "Bearer ' || current_setting('app.service_role_key') || '"}'::jsonb,
body := '{}'::jsonb
);
$$
);
7.13 Email Permissions
| Action | Member | Board | Admin |
|---|---|---|---|
| Receive automated emails | ✓ | ✓ | ✓ |
| View own email history | ✓ | ✓ | ✓ |
| View all email logs | - | - | ✓ |
| Edit email templates | - | - | ✓ |
| Send broadcast emails | - | - | ✓ |
| Send manual reminders | - | ✓ | ✓ |
| Configure email settings | - | - | ✓ |
| Test email connection | - | - | ✓ |
Phase 1: Current System Analysis (COMPLETED)
Current Tech Stack
| Layer | Technology |
|---|---|
| Framework | Nuxt 3 (Vue 3) - SSR disabled, CSR-only |
| UI Components | Vuetify 3 + Tailwind CSS + Custom SCSS |
| Database | NocoDB (REST API over PostgreSQL) |
| Authentication | Keycloak (OAuth2/OIDC) |
| File Storage | MinIO (S3-compatible) |
| Nodemailer + Handlebars templates | |
| State | Vue Composition API (no Pinia) |
Current Features Inventory
1. Member Management
- Data Model: Member with 20+ fields (name, email, phone, DOB, nationality, address, etc.)
- Member ID Format:
MUSA-YYYY-XXXX(auto-generated) - Status States: Active, Inactive, Pending, Expired
- Portal Tiers: admin, board, user
- Profile Images: Stored in MinIO
2. Dues/Subscription System
- Calculation: Due 1 year after last payment
- Fields Tracked:
membership_date_paid,payment_due_date,current_year_dues_paid - Reminders: 30-day advance warning, overdue notifications
- Auto-Status: Members 1+ year overdue marked Inactive
- Rates: €50 regular, €25 student, €35 senior, €75 family, €200 corporate
3. Events System
- Event Types: meeting, social, fundraiser, workshop, board-only
- RSVP System: confirmed, declined, pending, waitlist
- Guest Management: Extra guests per RSVP
- Pricing: Member vs non-member pricing
- Visibility: public, board-only, admin-only
- Calendar: FullCalendar integration with iCal feed
4. Authentication & Authorization
- Login Methods: OAuth (Keycloak) + Direct login (ROPC)
- Role System: Keycloak realm roles (monaco-admin, monaco-board, monaco-user)
- Session: Server-side with HTTP-only cookies (7-30 days)
- Rate Limiting: 5 attempts/15min, 1-hour IP block
- Signup Flow: Form → reCAPTCHA → Keycloak user → NocoDB member → Verification email
5. Three Dashboard Types
- Admin: Full system control, user management, settings, all features
- Board: Member directory, dues management, events, meetings, governance
- Member: Personal profile, events, payments, resources
Current Pain Points (to address in rebuild)
- CSR-only limits SEO and initial load performance
- NocoDB adds complexity vs direct database access
- String booleans ("true"/"false") cause type issues
- No payment history table (only last payment tracked)
- Vuetify + Tailwind overlap creates CSS conflicts
- Large monolithic layout files (700-800+ lines each)
Phase 2: New System Requirements
Core Requirements (confirmed)
- Beautiful, modern, responsive frontend (not generic Vue look)
- Member tracking with subscription/dues management
- Dues due 1 year after last payment
- Event calendar with RSVP system
- Board members can create/manage events
- Event features: capacity, +1 guests, member/non-member pricing
- Three dashboard types: Admin, Board, Member
- Signup system similar to current
- Manual payment tracking only (no Stripe integration)
- Email notifications (dues reminders, event updates)
- Document storage (meeting minutes, governance docs)
Deployment Strategy
- Replace entirely - Switch over when ready (no parallel systems)
- Manual data entry - Members will be entered manually (no migration scripts)
FRONTEND FRAMEWORK OPTIONS
Tier 1: Modern & Distinctive (Recommended)
1. Qwik + QwikCity ⭐ MOST INNOVATIVE
| Aspect | Details |
|---|---|
| What it is | Resumable framework - HTML loads instantly, JS loads on-demand |
| Unique Feature | Zero hydration - fastest possible load times |
| Learning Curve | Medium (JSX-like but different mental model) |
| Ecosystem | Growing - Auth.js, Drizzle, Modular Forms integrations |
| Benchmark Score | 93.8 (highest among all frameworks) |
Pros:
- Blazing fast initial load (no hydration delay)
- Feels like React/JSX but with better performance model
- Built-in form handling with Zod validation
- Server functions with
"use server"directive - Excellent TypeScript support
- Unique - won't look like every other site
Cons:
- Smaller community than React/Vue
- Fewer pre-built component libraries
- Newer framework (less battle-tested in production)
- Some patterns feel unfamiliar at first
Best For: Performance-focused apps where first impression matters
2. SolidStart (Solid.js) ⭐ MOST PERFORMANT REACTIVITY
| Aspect | Details |
|---|---|
| What it is | Fine-grained reactive framework with meta-framework |
| Unique Feature | No Virtual DOM - direct DOM updates via signals |
| Learning Curve | Medium (React-like JSX, different reactivity) |
| Ecosystem | Good - Ark UI, Kobalte for components |
| Benchmark Score | 92.2 |
Pros:
- Smallest bundle sizes in the industry
- React-like syntax (easy transition)
- True reactivity (no re-renders, just updates)
- Server functions and data loading built-in
- Growing rapidly in popularity
- Unique performance characteristics
Cons:
- Smaller ecosystem than React
- Fewer tutorials and resources
- Some React patterns don't translate directly
- Component libraries less mature
Best For: Highly interactive dashboards with lots of real-time updates
3. SvelteKit ⭐ BEST DEVELOPER EXPERIENCE
| Aspect | Details |
|---|---|
| What it is | Compiler-based framework with full-stack capabilities |
| Unique Feature | No virtual DOM, compiles to vanilla JS |
| Learning Curve | Low (closest to vanilla HTML/CSS/JS) |
| Ecosystem | Strong - Skeleton UI, Melt UI, Shadcn-Svelte |
| Benchmark Score | 91.0 |
Pros:
- Simplest syntax - looks like enhanced HTML
- Smallest learning curve
- Excellent built-in animations/transitions
- Strong TypeScript integration
- Great form handling
- Active, helpful community
- Svelte 5 runes make state even simpler
Cons:
- Different mental model from React/Vue
- Smaller job market (if that matters)
- Some advanced patterns less documented
- Breaking changes between Svelte 4 and 5
Best For: Clean, maintainable code with minimal boilerplate
4. Astro + React/Vue/Svelte Islands ⭐ MOST FLEXIBLE
| Aspect | Details |
|---|---|
| What it is | Content-focused framework with "islands" of interactivity |
| Unique Feature | Mix multiple frameworks, zero JS by default |
| Learning Curve | Low-Medium |
| Ecosystem | Excellent - use ANY UI library |
| Benchmark Score | 90.2 |
Pros:
- Use React, Vue, Svelte, or Solid components together
- Zero JavaScript shipped by default
- Excellent for content + interactive sections
- Built-in image optimization
- Great Supabase integration documented
- View Transitions API support
Cons:
- Not ideal for highly interactive SPAs
- Island architecture adds complexity
- More configuration for full interactivity
- Less unified than single-framework approach
Best For: Marketing site + member portal hybrid
Tier 2: Battle-Tested Mainstream
5. Next.js 15 (React)
| Aspect | Details |
|---|---|
| What it is | Most popular React meta-framework |
| Unique Feature | App Router, Server Components, huge ecosystem |
| Learning Curve | Medium-High (lots of concepts) |
| Ecosystem | Largest - shadcn/ui, Radix, everything |
| Benchmark Score | N/A (didn't query) |
Pros:
- Largest ecosystem and community
- Most job opportunities
- shadcn/ui provides beautiful, customizable components
- Excellent documentation
- Vercel hosting optimized
Cons:
- Can feel "generic" - many sites use it
- Complex mental model (Server vs Client components)
- Heavier than alternatives
- Vercel-centric development
6. Remix
| Aspect | Details |
|---|---|
| What it is | Full-stack React framework focused on web standards |
| Unique Feature | Nested routing, progressive enhancement |
| Learning Curve | Medium |
| Ecosystem | Good - React ecosystem compatible |
| Benchmark Score | 89.4 |
Pros:
- Web standards focused (works without JS)
- Excellent data loading patterns
- Great error handling
- Form handling is first-class
- Can deploy anywhere (not Vercel-locked)
Cons:
- Smaller community than Next.js
- Less "magic" means more manual work
- Merged with React Router (transition period)
7. TanStack Start (React)
| Aspect | Details |
|---|---|
| What it is | New full-stack framework from TanStack team |
| Unique Feature | Type-safe from database to UI |
| Learning Curve | Medium |
| Ecosystem | TanStack Query, Form, Router built-in |
| Benchmark Score | 80.7 |
Pros:
- Built by TanStack (Query, Router, Form authors)
- End-to-end type safety
- Modern patterns throughout
- Excellent data fetching built-in
Cons:
- Very new (beta/early stage)
- Smaller community
- Less documentation
- Rapidly evolving API
8. Nuxt 4 (Vue 3)
| Aspect | Details |
|---|---|
| What it is | Latest Vue meta-framework |
| Unique Feature | Familiar from current system |
| Learning Curve | Low (you know it) |
| Ecosystem | Good - Nuxt UI, PrimeVue |
Pros:
- Familiar - no learning curve
- Can reuse some current code/patterns
- Strong conventions
- Good TypeScript support now
Cons:
- User specifically wants to avoid "generic Vue look"
- Similar limitations to current system
- Less innovative than alternatives
9. Angular 19
| Aspect | Details |
|---|---|
| What it is | Google's enterprise framework |
| Unique Feature | Signals, standalone components, full framework |
| Learning Curve | High |
| Ecosystem | Enterprise-grade |
| Benchmark Score | 90.3 |
Pros:
- Complete framework (no decisions to make)
- Excellent for large applications
- Strong typing throughout
- Signals in Angular 19 are modern
Cons:
- Steeper learning curve
- More verbose
- "Enterprise" feel may not fit small org
- Overkill for this scale
Tier 3: Experimental/Niche
10. Leptos (Rust)
| Aspect | Details |
|---|---|
| What it is | Full-stack Rust framework |
| Unique Feature | WASM-based, extremely fast |
| Benchmark Score | 89.7 |
Pros:
- Blazing fast (Rust + WASM)
- Type safety at compile time
- Innovative approach
Cons:
- Requires learning Rust
- Small ecosystem
- Harder to find developers
- Overkill for this use case
11. Hono + HTMX
| Aspect | Details |
|---|---|
| What it is | Lightweight backend + hypermedia frontend |
| Unique Feature | Server-rendered, minimal JS |
| Benchmark Score | 92.8 |
Pros:
- Extremely lightweight
- Simple mental model
- Works on edge (Cloudflare Workers)
- Fast development
Cons:
- Less rich interactivity
- Different paradigm (hypermedia)
- Limited complex UI patterns
- Manual work for dashboards
UI COMPONENT LIBRARY OPTIONS
For React-based Frameworks (Next.js, Remix, TanStack)
| Library | Style | Customizable | Notes |
|---|---|---|---|
| shadcn/ui | Modern, clean | Fully (copy/paste) | Most popular, highly customizable |
| Radix Themes | Polished | Theme-based | Beautiful defaults, less work |
| Radix Primitives | Unstyled | Fully | Build completely custom |
| Ark UI | Unstyled | Fully | Works with multiple frameworks |
| Park UI | Pre-styled Ark | Moderate | Ark + beautiful defaults |
For Solid.js
| Library | Style | Notes |
|---|---|---|
| Kobalte | Unstyled | Radix-like primitives for Solid |
| Ark UI Solid | Unstyled | Same Ark, Solid version |
| Solid UI | Various | Community components |
For Svelte
| Library | Style | Notes |
|---|---|---|
| shadcn-svelte | Modern | Port of shadcn for Svelte |
| Skeleton UI | Tailwind | Full design system |
| Melt UI | Unstyled | Primitives for Svelte |
| Bits UI | Unstyled | Headless components |
For Qwik
| Library | Style | Notes |
|---|---|---|
| Qwik UI | Official | Growing component library |
| Custom + Tailwind | Any | Build from scratch |
For Vue/Nuxt
| Library | Style | Notes |
|---|---|---|
| shadcn-vue | Modern | Port of shadcn for Vue |
| Radix Vue | Unstyled | Radix primitives for Vue |
| Nuxt UI | Tailwind | Official Nuxt components |
| PrimeVue | Various | Comprehensive but generic |
DATABASE OPTIONS - DETAILED COMPARISON
Option 1: Supabase ⭐ RECOMMENDED
| Aspect | Details |
|---|---|
| Type | PostgreSQL + Auth + Storage + Realtime |
| Hosting | Managed cloud or self-hosted |
| Pricing | Free tier, then $25/mo |
Pros:
- All-in-one: Database + Auth + File Storage + Realtime
- PostgreSQL (industry standard, powerful)
- Row-level security built-in
- Excellent TypeScript support
- Auto-generated APIs
- Real-time subscriptions
- Built-in auth (replaces Keycloak)
- Dashboard for data management
- Can self-host if needed
Cons:
- Vendor lock-in (mitigated by self-host option)
- Learning curve for RLS policies
- Free tier has limits
- Less control than raw PostgreSQL
Best For: Rapid development with full-stack features
Option 2: PostgreSQL + Prisma
| Aspect | Details |
|---|---|
| Type | Direct database + Type-safe ORM |
| Hosting | Any PostgreSQL host (Neon, Railway, etc.) |
| Pricing | Database hosting costs only |
Pros:
- Full control over database
- Prisma schema is very readable
- Excellent TypeScript types
- Migrations handled automatically
- Works with any PostgreSQL
- Large community
Cons:
- Need separate auth solution
- Need separate file storage
- More setup work
- Prisma can be slow for complex queries
Best For: Maximum control and flexibility
Option 3: PostgreSQL + Drizzle ORM
| Aspect | Details |
|---|---|
| Type | Direct database + Lightweight ORM |
| Hosting | Any PostgreSQL host |
| Pricing | Database hosting costs only |
Pros:
- Closer to SQL (less abstraction)
- Faster than Prisma
- Smaller bundle size
- TypeScript-first
- Better for complex queries
- Growing rapidly
Cons:
- Newer, smaller community
- Less documentation
- Need separate auth/storage
- More manual migration work
Best For: Performance-critical apps, SQL-comfortable teams
Option 4: PlanetScale + Drizzle
| Aspect | Details |
|---|---|
| Type | Serverless MySQL |
| Hosting | Managed cloud only |
| Pricing | Free tier, then usage-based |
Pros:
- Serverless scaling
- Branching (like git for databases)
- No connection limits
- Fast globally
Cons:
- MySQL not PostgreSQL
- No foreign keys (by design)
- Vendor lock-in
- Can get expensive at scale
Best For: Serverless deployments, edge functions
Option 5: Keep NocoDB
| Aspect | Details |
|---|---|
| Type | Spreadsheet-like interface over database |
| Hosting | Self-hosted or cloud |
Pros:
- Already configured
- Non-technical users can edit data
- Flexible schema changes
- API already exists
Cons:
- Adds complexity layer
- String booleans issue
- Less type safety
- Performance overhead
- Limited query capabilities
Best For: Non-technical admin users need direct access
AUTHENTICATION OPTIONS - DETAILED COMPARISON
Option 1: Supabase Auth ⭐ IF USING SUPABASE
| Aspect | Details |
|---|---|
| Type | Built into Supabase |
| Providers | Email, OAuth (Google, GitHub, etc.), Magic Link |
Pros:
- Integrated with Supabase (one platform)
- Row-level security integration
- Simple setup
- Built-in user management
- Social logins included
- Magic link support
Cons:
- Tied to Supabase
- Less customizable than Keycloak
- No SAML/enterprise SSO on free tier
Option 2: Keep Keycloak
| Aspect | Details |
|---|---|
| Type | Self-hosted identity provider |
| Providers | Everything (OIDC, SAML, social, etc.) |
Pros:
- Already configured and working
- Enterprise-grade features
- Full control
- SAML support
- Custom themes
- User federation
Cons:
- Complex to maintain
- Heavy resource usage
- Overkill for small org
- Requires Java expertise
- Self-hosted burden
Option 3: Better Auth ⭐ MODERN CHOICE
| Aspect | Details |
|---|---|
| Type | Framework-agnostic TypeScript auth |
| Providers | Email, OAuth, Magic Link, Passkeys |
Pros:
- Modern, TypeScript-first
- Works with any framework
- Plugin system for features
- Session management built-in
- Two-factor auth support
- Lightweight
Cons:
- Newer (less battle-tested)
- Self-implemented
- Need own user storage
Option 4: Auth.js (NextAuth)
| Aspect | Details |
|---|---|
| Type | Framework-agnostic auth library |
| Providers | 50+ OAuth providers |
Pros:
- Massive provider support
- Well documented
- Active development
- Works with Qwik, SvelteKit, etc.
Cons:
- Complex configuration
- Database adapter setup
- v5 migration issues
- Can be finicky
Option 5: Clerk
| Aspect | Details |
|---|---|
| Type | Auth-as-a-service |
| Providers | Everything + beautiful UI |
Pros:
- Beautiful pre-built components
- Zero config setup
- Great DX
- Organizations/teams built-in
Cons:
- Expensive at scale
- Vendor lock-in
- Less control
- Monthly costs
Option 6: Lucia Auth
| Aspect | Details |
|---|---|
| Type | Low-level auth library |
| Note | Being deprecated in favor of guides |
Pros:
- Full control
- Lightweight
- Educational
Cons:
- Being sunset
- More DIY work
CHOSEN STACK (FINAL)
| Layer | Technology | Rationale |
|---|---|---|
| Framework | SvelteKit 2 | Best DX, simple syntax, excellent performance |
| UI Components | shadcn-svelte + Bits UI | Beautiful, customizable, accessible |
| Styling | Tailwind CSS 4 | Utility-first, works great with shadcn |
| Database | Supabase (PostgreSQL) | All-in-one, managed, real-time capable |
| Auth | Supabase Auth | Integrated with database, simple setup |
| File Storage | Supabase Storage | Profile images, documents |
| Design | Glass-morphism (evolved) | Modern, distinctive, refined |
| Language | TypeScript | Type safety throughout |
Phase 3: Architecture Design
Project Structure
monacousa-portal-2026/
├── src/
│ ├── lib/
│ │ ├── components/ # Reusable UI components
│ │ │ ├── ui/ # shadcn-svelte base components
│ │ │ ├── dashboard/ # Dashboard widgets
│ │ │ ├── members/ # Member-related components
│ │ │ ├── events/ # Event components
│ │ │ └── layout/ # Layout components (sidebar, header)
│ │ ├── server/ # Server-only utilities
│ │ │ ├── supabase.ts # Supabase server client
│ │ │ └── auth.ts # Auth helpers
│ │ ├── stores/ # Svelte stores for state
│ │ ├── utils/ # Shared utilities
│ │ │ ├── types.ts # TypeScript types
│ │ │ ├── constants.ts # App constants
│ │ │ └── helpers.ts # Helper functions
│ │ └── supabase.ts # Supabase client (browser)
│ │
│ ├── routes/
│ │ ├── +layout.svelte # Root layout
│ │ ├── +layout.server.ts # Root server load (auth)
│ │ ├── +page.svelte # Landing page
│ │ │
│ │ ├── (auth)/ # Auth group (guest only)
│ │ │ ├── login/
│ │ │ ├── signup/
│ │ │ ├── forgot-password/
│ │ │ └── callback/ # OAuth callback
│ │ │
│ │ ├── (app)/ # Protected app group
│ │ │ ├── +layout.svelte # App layout with sidebar
│ │ │ ├── +layout.server.ts # Auth guard
│ │ │ │
│ │ │ ├── dashboard/ # User dashboard
│ │ │ ├── profile/ # User profile
│ │ │ ├── events/ # Events calendar/list
│ │ │ ├── payments/ # Dues/payments view
│ │ │ │
│ │ │ ├── board/ # Board-only routes
│ │ │ │ ├── +layout.server.ts # Board guard
│ │ │ │ ├── dashboard/
│ │ │ │ ├── members/
│ │ │ │ ├── events/ # Event management
│ │ │ │ └── meetings/
│ │ │ │
│ │ │ └── admin/ # Admin-only routes
│ │ │ ├── +layout.server.ts # Admin guard
│ │ │ ├── dashboard/
│ │ │ ├── members/
│ │ │ ├── users/
│ │ │ ├── events/
│ │ │ └── settings/
│ │ │
│ │ └── api/ # API routes (if needed)
│ │
│ ├── hooks.server.ts # Server hooks (Supabase SSR)
│ └── app.d.ts # TypeScript declarations
│
├── static/ # Static assets
├── supabase/ # Supabase local dev
│ └── migrations/ # Database migrations
├── tests/ # Test files
├── svelte.config.js
├── tailwind.config.ts
├── vite.config.ts
└── package.json
Database Schema (Supabase/PostgreSQL)
-- USERS (managed by Supabase Auth)
-- auth.users table is automatic
-- MEMBERS (extends auth.users)
CREATE TABLE public.members (
id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
member_id TEXT UNIQUE NOT NULL, -- MUSA-2026-0001 format
first_name TEXT NOT NULL,
last_name TEXT NOT NULL,
email TEXT UNIQUE NOT NULL,
phone TEXT,
date_of_birth DATE,
address TEXT,
nationality TEXT[], -- Array of country codes ['FR', 'US']
-- Membership
role TEXT NOT NULL DEFAULT 'member' CHECK (role IN ('member', 'board', 'admin')),
membership_status TEXT NOT NULL DEFAULT 'pending'
CHECK (membership_status IN ('active', 'inactive', 'pending', 'expired')),
member_since DATE DEFAULT CURRENT_DATE,
-- Profile
avatar_url TEXT,
bio TEXT,
-- Timestamps
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- DUES/PAYMENTS (tracks payment history)
CREATE TABLE public.dues_payments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
member_id UUID NOT NULL REFERENCES public.members(id) ON DELETE CASCADE,
amount DECIMAL(10,2) NOT NULL,
currency TEXT DEFAULT 'EUR',
payment_date DATE NOT NULL,
due_date DATE NOT NULL, -- When this payment period ends
payment_method TEXT, -- 'bank_transfer', 'cash', etc.
reference TEXT, -- Transaction reference
notes TEXT,
recorded_by UUID REFERENCES public.members(id), -- Who recorded this payment
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- EVENTS
CREATE TABLE public.events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title TEXT NOT NULL,
description TEXT,
event_type TEXT NOT NULL CHECK (event_type IN ('social', 'meeting', 'fundraiser', 'workshop', 'other')),
start_datetime TIMESTAMPTZ NOT NULL,
end_datetime TIMESTAMPTZ NOT NULL,
location TEXT,
-- Capacity & Pricing
max_attendees INTEGER,
max_guests_per_member INTEGER DEFAULT 1,
member_price DECIMAL(10,2) DEFAULT 0,
non_member_price DECIMAL(10,2) DEFAULT 0,
-- Visibility
visibility TEXT NOT NULL DEFAULT 'members'
CHECK (visibility IN ('public', 'members', 'board', 'admin')),
status TEXT NOT NULL DEFAULT 'published'
CHECK (status IN ('draft', 'published', 'cancelled', 'completed')),
-- Metadata
created_by UUID NOT NULL REFERENCES public.members(id),
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- EVENT RSVPs
CREATE TABLE public.event_rsvps (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
event_id UUID NOT NULL REFERENCES public.events(id) ON DELETE CASCADE,
member_id UUID NOT NULL REFERENCES public.members(id) ON DELETE CASCADE,
status TEXT NOT NULL DEFAULT 'confirmed'
CHECK (status IN ('confirmed', 'declined', 'waitlist', 'cancelled')),
guest_count INTEGER DEFAULT 0,
guest_names TEXT[],
payment_status TEXT DEFAULT 'not_required'
CHECK (payment_status IN ('not_required', 'pending', 'paid')),
attended BOOLEAN DEFAULT FALSE,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(event_id, member_id)
);
-- DOCUMENTS (meeting minutes, governance, etc.)
CREATE TABLE public.documents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title TEXT NOT NULL,
description TEXT,
category TEXT NOT NULL CHECK (category IN ('meeting_minutes', 'governance', 'financial', 'other')),
file_path TEXT NOT NULL, -- Supabase Storage path
file_name TEXT NOT NULL,
file_size INTEGER,
mime_type TEXT,
visibility TEXT NOT NULL DEFAULT 'board'
CHECK (visibility IN ('members', 'board', 'admin')),
uploaded_by UUID NOT NULL REFERENCES public.members(id),
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- EMAIL NOTIFICATIONS LOG
CREATE TABLE public.email_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
recipient_id UUID REFERENCES public.members(id),
recipient_email TEXT NOT NULL,
email_type TEXT NOT NULL, -- 'dues_reminder', 'event_invite', etc.
subject TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'sent'
CHECK (status IN ('sent', 'failed', 'bounced')),
sent_at TIMESTAMPTZ DEFAULT NOW(),
error_message TEXT
);
-- COMPUTED VIEW: Member with dues status
CREATE VIEW public.members_with_dues AS
SELECT
m.*,
dp.payment_date as last_payment_date,
dp.due_date as current_due_date,
CASE
WHEN dp.due_date IS NULL THEN 'never_paid'
WHEN dp.due_date < CURRENT_DATE THEN 'overdue'
WHEN dp.due_date < CURRENT_DATE + INTERVAL '30 days' THEN 'due_soon'
ELSE 'current'
END as dues_status,
CASE
WHEN dp.due_date < CURRENT_DATE
THEN CURRENT_DATE - dp.due_date
ELSE NULL
END as days_overdue
FROM public.members m
LEFT JOIN LATERAL (
SELECT payment_date, due_date
FROM public.dues_payments
WHERE member_id = m.id
ORDER BY due_date DESC
LIMIT 1
) dp ON true;
-- ROW LEVEL SECURITY
ALTER TABLE public.members ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.dues_payments ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.events ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.event_rsvps ENABLE ROW LEVEL SECURITY;
-- Members: Users can read all, update own, admins can do anything
CREATE POLICY "Members are viewable by authenticated users"
ON public.members FOR SELECT
TO authenticated
USING (true);
CREATE POLICY "Users can update own profile"
ON public.members FOR UPDATE
TO authenticated
USING (auth.uid() = id);
CREATE POLICY "Admins can insert members"
ON public.members FOR INSERT
TO authenticated
WITH CHECK (
EXISTS (SELECT 1 FROM public.members WHERE id = auth.uid() AND role = 'admin')
OR auth.uid() = id -- Self-registration
);
-- Events: Based on visibility
CREATE POLICY "Events viewable based on visibility"
ON public.events FOR SELECT
TO authenticated
USING (
visibility = 'members'
OR visibility = 'public'
OR (visibility = 'board' AND EXISTS (
SELECT 1 FROM public.members WHERE id = auth.uid() AND role IN ('board', 'admin')
))
OR (visibility = 'admin' AND EXISTS (
SELECT 1 FROM public.members WHERE id = auth.uid() AND role = 'admin'
))
);
-- Board/Admin can manage events
CREATE POLICY "Board can manage events"
ON public.events FOR ALL
TO authenticated
USING (
EXISTS (SELECT 1 FROM public.members WHERE id = auth.uid() AND role IN ('board', 'admin'))
);
Authentication Flow
1. SIGNUP FLOW
┌─────────────┐ ┌──────────────┐ ┌─────────────┐
│ /signup │────▶│ Supabase Auth│────▶│ Email Verify│
│ Form │ │ signUp() │ │ Link Sent │
└─────────────┘ └──────────────┘ └─────────────┘
│
▼
┌──────────────┐
│ Create Member│
│ Record (RLS) │
└──────────────┘
2. LOGIN FLOW
┌─────────────┐ ┌──────────────┐ ┌─────────────┐
│ /login │────▶│ Supabase Auth│────▶│ Set Session │
│ Form │ │ signIn() │ │ Cookie │
└─────────────┘ └──────────────┘ └─────────────┘
│
▼
┌──────────────┐
│ Redirect to │
│ Dashboard │
└──────────────┘
3. PROTECTED ROUTES
┌─────────────┐ ┌──────────────┐ ┌─────────────┐
│ Request │────▶│ hooks.server │────▶│ Check Role │
│ /admin/* │ │ getSession() │ │ in members │
└─────────────┘ └──────────────┘ └─────────────┘
│ │
▼ ▼
┌──────────────┐ ┌─────────────┐
│ Valid? │ │ Redirect or │
│ Yes → Render │ │ 403 Error │
└──────────────┘ └─────────────┘
UI Component Library
Using shadcn-svelte with custom glass-morphism theme:
// tailwind.config.ts - Glass theme extensions
export default {
theme: {
extend: {
colors: {
monaco: {
50: '#fef2f2',
100: '#fee2e2',
500: '#ef4444',
600: '#dc2626', // Primary
700: '#b91c1c',
900: '#7f1d1d',
}
},
backdropBlur: {
xs: '2px',
},
boxShadow: {
'glass': '0 8px 32px rgba(0, 0, 0, 0.1)',
'glass-lg': '0 25px 50px rgba(0, 0, 0, 0.15)',
}
}
}
}
Custom Glass Components:
GlassCard- Frosted glass containerGlassSidebar- Navigation sidebarGlassButton- Glass-effect buttonsGlassInput- Form inputs with glass stylingStatCard- Dashboard stat displayEventCard- Event display cardMemberCard- Member profile cardDuesStatusBadge- Dues status indicator
Key Features Implementation
1. Member Management
- View all members (admin/board)
- Edit member details
- Upload profile photos (Supabase Storage)
- Track membership status
- Filter by status, nationality, dues
2. Dues Tracking
- Payment history table
- Auto-calculate due dates (1 year from payment)
- Visual status indicators
- Overdue notifications
- Manual payment recording
3. Event System
- Calendar view (FullCalendar or custom)
- List view with filters
- RSVP with guest management
- Attendance tracking
- Event creation (board/admin)
4. Three Dashboards
| Dashboard | Features |
|---|---|
| Member | Profile, upcoming events, dues status, quick actions |
| Board | Member stats, pending applications, dues overview, event management |
| Admin | System stats, user management, all member data, settings |
5. Email Notifications
- Dues reminder emails (30 days before, on due date, overdue)
- Event invitation/updates
- Welcome email on signup
- Password reset emails (Supabase built-in)
6. Document Storage
- Upload meeting minutes, governance docs
- Organize by category
- Visibility controls (members/board/admin)
- Download/preview functionality
Phase 4: Implementation Roadmap
Stage 1: Foundation (Week 1-2)
- Initialize SvelteKit project with TypeScript
- Set up Tailwind CSS 4 + shadcn-svelte
- Configure Supabase project
- Create database schema + migrations
- Implement Supabase SSR hooks
- Build base layout components
Stage 2: Authentication (Week 2-3)
- Login/Signup pages
- Email verification flow
- Password reset
- Protected route guards
- Role-based access control
Stage 3: Core Features (Week 3-5)
- Member dashboard
- Profile management
- Member directory (board/admin)
- Dues tracking system
- Payment recording
Stage 4: Events (Week 5-6)
- Event listing/calendar
- Event detail view
- RSVP system
- Event creation (board)
- Attendance tracking
Stage 5: Admin Features (Week 6-7)
- Admin dashboard
- User management
- System settings
- Data export
Stage 6: Polish (Week 7-8)
- Glass-morphism styling refinement
- Responsive design
- Performance optimization
- Testing
- Documentation
Verification Plan
Development Testing
# Start Supabase local
npx supabase start
# Run dev server
npm run dev
# Type checking
npm run check
Manual Testing Checklist
- User can sign up and receive verification email
- User can log in and see dashboard
- Member can view/edit profile
- Member can view events and RSVP
- Board member can access board dashboard
- Board member can create/manage events
- Board member can view member directory
- Board member can record dues payments
- Admin can access all features
- Admin can manage user roles
- Role-based routing works correctly
- Responsive on mobile/tablet/desktop
Browser Testing
- Chrome, Firefox, Safari, Edge
- iOS Safari, Android Chrome
Data Entry Strategy
Since members will be entered manually (no automated migration):
Admin Setup
- Create first admin account via Supabase dashboard
- Manually set
role = 'admin'in members table - Admin can then add other members through the portal
Member Entry Options
- Admin adds members - Admin creates accounts for existing members
- Self-registration - Members sign up themselves
- Invite system - Admin sends email invites with signup links
Initial Launch Checklist
- Admin account created and verified
- Board member accounts created
- Test member account for verification
- Email templates configured (Supabase)
Files to Create
| Path | Purpose |
|---|---|
monacousa-portal-2026/ |
New project root |
src/hooks.server.ts |
Supabase SSR setup |
src/lib/supabase.ts |
Client initialization |
src/lib/server/supabase.ts |
Server client |
src/routes/+layout.svelte |
Root layout |
src/routes/(auth)/login/+page.svelte |
Login page |
src/routes/(auth)/signup/+page.svelte |
Signup page |
src/routes/(app)/+layout.svelte |
App layout |
src/routes/(app)/dashboard/+page.svelte |
Member dashboard |
supabase/migrations/001_schema.sql |
Database schema |