Database Audit — 2026-03-24
This document records the findings and fixes from a comprehensive Supabase database audit conducted on 2026-03-24. All changes were applied via migration 20260324000008_db_audit_fix.sql.
Why This Audit Was Done
The admin portal was showing empty tables (users, events) despite data existing in the database. Investigation revealed a cascade of issues: RLS policies causing infinite recursion, column name mismatches between migrations and frontend code, dirty enum data, and missing constraints.
The audit was performed by connecting directly to the live Supabase instance via the PostgREST API (service role) to verify the real schema — not just the migration files, which were out of sync with the actual DB state.
Findings and Fixes
1. RLS Infinite Recursion (Critical)
Problem: Several admin-facing RLS policies on profiles checked profiles.role to authorize access. This caused the query to re-trigger the same policy, creating infinite recursion. Supabase silently returns 0 rows instead of raising an error — so admin queries appeared to succeed but returned nothing.
Affected policies: admin_read_all_profiles, admin_update_any_profile, and all other policies that did:
-- BAD: queries profiles from within a profiles policy
USING (EXISTS (SELECT 1 FROM profiles WHERE id = auth.uid() AND role = 'admin'))Fix: Created two SECURITY DEFINER helper functions that run with elevated privileges, bypassing RLS:
CREATE OR REPLACE FUNCTION public.is_admin() RETURNS BOOLEAN ...
CREATE OR REPLACE FUNCTION public.is_super_admin() RETURNS BOOLEAN ...All admin policies were rewritten to use USING (public.is_admin()). This pattern was applied to: profiles, events, app_config, app_issues, moderator_permissions, verification_requests, pr_profiles.
2. profiles.role — Invalid Values and Missing super_admin
Problem: The role CHECK constraint did not include super_admin, which is used in the codebase. There were also some uppercase role values in old rows.
Fix:
- Lowercased all existing role values
- Dropped and recreated the CHECK constraint to include:
user,moderator,admin,super_admin,vendor,partner,influencer,pr,dj_artist
3. profiles.status Column Missing
Problem: The admin portal code references a status column (active, suspended, banned, pending). This column was defined in migration 006 but never applied to the live database.
Fix: Added status TEXT DEFAULT 'active' CHECK (status IN ('active', 'suspended', 'banned', 'pending')) and backfilled existing rows from is_active (true → active, false → suspended).
4. Overly Broad Public Profile SELECT
Problem: The "Public profiles are viewable by everyone" RLS policy exposed all columns including email, phone_number, date_of_birth, latitude, longitude to unauthenticated users.
Fix: Dropped the broad policy. Replaced with:
"Users can read own full profile"— full access to own row"Authenticated users can read safe profile fields"— authenticated users can read all rows (but thepublic_profilesVIEW should be used for public-facing reads, as it excludes sensitive columns)
5. events.type — Dirty Enum Data
Problem: The type column was supposed to represent event modality (in_person, virtual, hybrid) but contained free-text genre values from an early period when the column was misused: nightclub, concert, festival, aperitivo, bar, club, inPerson, in-person.
Fix: Data was corrected before adding the CHECK constraint:
UPDATE events SET type = 'in_person' WHERE type IN ('inPerson', 'in-person', 'nightclub', 'concert', 'festival', 'aperitivo', 'bar', 'club');Then added CHECK (type IN ('in_person', 'virtual', 'hybrid')).
6. events.category — Not Normalized
Problem: Category values were mixed-case (e.g., Social, Music).
Fix: Lowercased all values. Added CHECK constraint with all valid categories:
social, nightlife, music, arts, food_drinks, clubbing, party, live_music, tech, sports, wellness, culture, networking, generale
7. events Defaults Missing
Problem: events.type and events.category had no DEFAULT values, causing vendor INSERT failures when these fields were omitted.
Fix: Set DEFAULT 'in_person' and DEFAULT 'generale'. (This was migration 007, now consolidated.)
8. events.status — No CHECK Constraint
Problem: The status column had no validation. Any string could be inserted.
Fix: Added CHECK (status IN ('draft', 'active', 'published', 'cancelled')).
9. chats.name for Direct Messages
Problem: Direct message chats had name = 'Direct Chat' — a meaningless placeholder instead of NULL.
Fix:
UPDATE chats SET name = NULL WHERE type = 'direct' AND name = 'Direct Chat';Also added CHECK (type IN ('direct', 'group', 'crew', 'squad', 'venue')).
10. chat_participants — No Role/Status Validation
Problem: role and status columns accepted any string.
Fix:
roleCHECK:owner,admin,memberstatusCHECK:active,muted,banned,left
11. Dropped Dead Tables
| Table | Reason |
|---|---|
_deprecated_posts | 0 rows, already had “_deprecated” prefix, no active RLS policies |
friendships | Exact structural duplicate of connections, 0 rows, never populated |
12. campaigns — FK and Constraints
Problem: creator_id had no FK reference to profiles. type and status had no validation.
Fix:
- Added FK:
creator_id REFERENCES profiles(id) ON DELETE SET NULL - Added
typeCHECK:social,environmental,music,health,education,business,art,sports,culture,other - Added
statusCHECK:active,completed,cancelled,draft
Note: All 23 existing campaigns have
creator_id = NULL(system/seed data). The column remains nullable. Enforce non-null at the application layer until seed data is replaced.
13. app_config — Duplicate Keys
Problem: Three keys were duplicated under different names:
| Removed key | Replaced by |
|---|---|
max_events_per_day_user | event_rate_limit_user (same value: 10) |
max_events_per_day_venue | event_rate_limit_venue (same value: 50) |
feedback_survey_percentage | feedback_survey_pct (value 15 removed, 20 kept) |
14. app_issues — Issue Tracking Workflow
Problem: No way to assign issues to admins or track resolution.
Fix: Added columns:
assigned_to UUIDFK →profiles(id)resolved_by UUIDFK →profiles(id)resolved_at TIMESTAMPTZ- Index on
assigned_to
Intentional Non-Changes
The following issues were identified but deliberately left for future migrations:
| Issue | Reason not changed |
|---|---|
profiles counter columns always 0 | Dropping risks app crashes; no triggers exist. Use JOIN instead. |
comments table — unclear usage | Non-empty, kept. Usage audit needed. |
followers + user_follows duplicate | Product decision required on social model |
squads + crews dual tables | Used by different migrations; risky to consolidate without full audit |
connections FK → auth.users (not profiles) | Low risk; changing requires constraint recreation |
campaigns.creator_id nullable | All 23 rows are NULL (seed data) |
events.tags TEXT[] alongside event_tags | Backward compat; remove after app fully migrates to junction table |
Frontend Fix: events.max_attendees
Separately from the database migration, the admin portal had a bug where the event capacity column was queried as capacity instead of max_attendees. Fixed in:
web/app/api/admin/events/route.ts— SELECT now includesmax_attendeesweb/app/admin/(dashboard)/events/page.tsx— interface and display updated
How to Apply
Migration 20260324000008_db_audit_fix.sql is idempotent where possible (uses IF NOT EXISTS, DROP CONSTRAINT IF EXISTS, ADD COLUMN IF NOT EXISTS).
# Via Supabase CLI (requires SUPABASE_ACCESS_TOKEN)
SUPABASE_ACCESS_TOKEN=<token> npx supabase db push
# Or paste the SQL directly into the Supabase SQL editor
# Dashboard → SQL Editor → paste supabase/migrations/20260324000008_db_audit_fix.sql