Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .env
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
DATABASE_URL="postgresql://user:password@host:port/db"
# POSTGRES_URL must be set in Vercel environment variables for production.
# For local development, add POSTGRES_URL to .env.local instead.
# POSTGRES_URL=
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,5 @@ aef_index.csv
.env.local
.env.production.local
*.log
deno.json
edge_function/
80 changes: 80 additions & 0 deletions CLERK_SUPABASE_FIX.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# Clerk-Supabase Auth Synchronization Fix

## Problem Summary

Authenticated users were unable to preview their chats in QCX because the Clerk authentication system and the QCX-BACKEND Supabase backend were not properly synchronized. The root cause was that the `clerk_user_id` column on the `public.users` table was **never being populated**, which caused all Clerk-aware Row Level Security (RLS) policies to fail — specifically, the `is_clerk_user()` function always returned `false`, blocking all authenticated users from accessing any data.

## Root Cause Analysis

The synchronization failure stemmed from **three conflicting user creation paths** that each created user records differently without establishing the Clerk-to-Supabase ID bridge:

### Path 1: Supabase `handle_new_user` Trigger
When a user signed up via Clerk, Clerk's JWT was passed to Supabase. The `handle_new_user` trigger fired on `auth.users` insert and created a `public.users` record with the Supabase-generated UUID as `id`, but it **never set `clerk_user_id`**.

### Path 2: Next.js Webhook Route (`/api/clerk/webhook`)
The webhook received Clerk events and used Drizzle ORM to upsert into the **QCX app's own database** (a separate connection via `DATABASE_URL`). It created records with `clerk_user_id` set, but these records lived in the app's database, **not in QCX-BACKEND**.

### Path 3: Supabase Edge Function (`database-access`)
The edge function received the same Clerk webhooks and performed a direct table upsert into QCX-BACKEND. However, it was using the Clerk ID (e.g., `user_abc123`) as the value for the `id` column — which is a `uuid` type — and it **never set `clerk_user_id`**.

### The Critical Gap
All three paths operated in isolation. None of them linked the Supabase UUID to the Clerk ID in the `clerk_user_id` column. Since all RLS policies depend on `is_clerk_user(user_id)` which checks `clerk_user_id = clerk_id()`, and `clerk_user_id` was always `NULL`, **every RLS policy denied access**.

## Fix Applied

### QCX-BACKEND (Supabase) — Direct SQL/Migration Fixes

1. **`sync_clerk_user()` RPC function**: A canonical function that handles all three cases — existing user by Clerk ID, existing user by email (linking), and new user creation. Returns the Supabase UUID for the record.

2. **Updated `handle_new_user` trigger**: Now extracts the Clerk user ID from `auth.jwt()` metadata and stores it in `clerk_user_id`.

3. **Updated `clerk_id()` function**: Correctly extracts the Clerk user ID from the JWT `sub` claim: `SELECT auth.jwt() ->> 'sub'`.

4. **Updated `is_clerk_user(user_id)` function**: Properly checks that the user's `clerk_user_id` matches the current session's Clerk ID.

5. **Updated all RLS policies** on 8 tables: `users`, `chats`, `messages`, `calendar_notes`, `chat_participants`, `locations`, `system_prompts`, `visualizations`. All policies now correctly use `is_clerk_user()` alongside `auth.uid()`.

6. **`users_updated_at` trigger**: Ensures the `updated_at` timestamp is properly maintained on user records.

7. **Deployed edge function v13**: The `database-access` edge function now calls `sync_clerk_user()` RPC instead of performing direct table upserts.

### QCX (Next.js App) — Codebase Fixes

1. **Updated webhook route** (`app/api/clerk/webhook/route.ts`): Now syncs users to **both** the app's database (via Drizzle) and QCX-BACKEND (via Supabase RPC). This ensures both systems have consistent user records with the correct `clerk_user_id`.

2. **Updated schema** (`lib/db/schema.ts`): Added `first_name`, `last_name`, `avatar_url`, `created_at`, `updated_at`, `username`, `phone_number`, and `metadata` columns to match the QCX-BACKEND users table.

3. **Updated `resolveClerkUserToDbUser`** (`lib/auth/get-current-user.ts`): Now syncs full profile data from Clerk (name, avatar) when resolving users, and properly links existing email-based records to their Clerk ID.

4. **New Drizzle migration** (`drizzle/migrations/0003_clerk_auth_sync.sql`): Persists all the QCX-BACKEND SQL changes (functions, triggers, RLS policies) into the repo's tracked migration system.

## Verification

All 6 critical components verified as active in QCX-BACKEND:
- `sync_clerk_user` function: active
- `clerk_id` function: active
- `is_clerk_user` function: active
- `clerk_user_id` column on `users`: present
- `on_auth_user_created` trigger: active
- `users_updated_at` trigger: active
- Edge function `database-access` v13: deployed and ACTIVE
- RLS policies: 12 policies across 8 tables, all using `is_clerk_user()`
Comment on lines +53 to +61

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Fix the verification count.

The section says “All 6 critical components,” but the list contains 8 items. That makes the verification summary internally inconsistent and can mislead readers about what was actually checked.

📝 Proposed fix
-All 6 critical components verified as active in QCX-BACKEND:
+All 8 critical components verified as active in QCX-BACKEND:
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
All 6 critical components verified as active in QCX-BACKEND:
- `sync_clerk_user` function: active
- `clerk_id` function: active
- `is_clerk_user` function: active
- `clerk_user_id` column on `users`: present
- `on_auth_user_created` trigger: active
- `users_updated_at` trigger: active
- Edge function `database-access` v13: deployed and ACTIVE
- RLS policies: 12 policies across 8 tables, all using `is_clerk_user()`
All 8 critical components verified as active in QCX-BACKEND:
- `sync_clerk_user` function: active
- `clerk_id` function: active
- `is_clerk_user` function: active
- `clerk_user_id` column on `users`: present
- `on_auth_user_created` trigger: active
- `users_updated_at` trigger: active
- Edge function `database-access` v13: deployed and ACTIVE
- RLS policies: 12 policies across 8 tables, all using `is_clerk_user()`
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@CLERK_SUPABASE_FIX.md` around lines 53 - 61, Fix the verification summary in
the CLERK_SUPABASE_FIX.md section so the stated component count matches the
actual list. Update the summary text that says “All 6 critical components” to
reflect the full set of items currently enumerated, and keep the wording
consistent with the verification bullets that mention sync_clerk_user, clerk_id,
is_clerk_user, clerk_user_id, on_auth_user_created, users_updated_at,
database-access, and the RLS policies.


## How It Works After the Fix

When a user authenticates with Clerk:

1. **Clerk** issues a JWT containing the Clerk user ID as the `sub` claim.
2. The **Next.js app** uses Clerk middleware to validate the JWT and extract the Clerk ID.
3. `resolveClerkUserToDbUser()` resolves the Clerk ID to the Supabase UUID, creating/linking the record if needed.
4. The **webhook route** simultaneously syncs the user to QCX-BACKEND via `sync_clerk_user()`.
5. When querying chats, RLS policies check `is_clerk_user(user_id)` which now correctly returns `true` because `clerk_user_id` is properly set.
6. The user can now see their chat history.

## Deployment Notes

- The Supabase changes are **already live** in QCX-BACKEND (project: `mofqapxwyphzjrqegjeq`).
- The edge function is deployed as version 13 and is ACTIVE.
- The Next.js codebase changes are committed and ready for deployment.
- The Drizzle migration (`0003_clerk_auth_sync.sql`) should be run via `npm run db:migrate` or `bun run db:migrate` (which sets `EXECUTE_MIGRATIONS=true`).
- The `edge_function/` directory contains the deployed edge function source for reference.
106 changes: 79 additions & 27 deletions app/api/clerk/webhook/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { WebhookEvent } from '@clerk/nextjs/server'
import { db } from '@/lib/db'
import { users } from '@/lib/db/schema'
import { eq } from 'drizzle-orm'
import { createClient } from '@supabase/supabase-js'

export async function POST(req: Request) {
// You can find this in the Clerk Dashboard -> Webhooks -> choose the endpoint
Expand Down Expand Up @@ -50,50 +51,101 @@ export async function POST(req: Request) {
}

// Do something with the payload
// For this guide, you can log the payload to your console
const { id } = evt.data
const eventType = evt.type

if (eventType === 'user.created' || eventType === 'user.updated') {
const { id, email_addresses, first_name, last_name } = evt.data as any
const email = email_addresses[0]?.email_address
const { id: clerkId, email_addresses, first_name, last_name } = evt.data as any
const email = email_addresses?.[0]?.email_address || null
const firstName = first_name || null
const lastName = last_name || null
const avatarUrl = (evt.data as any).image_url || null

if (id) {
// Upsert user
const [existingUser] = await db.select()
if (clerkId) {
// ============================================================
// SYNC TO QCX APP DATABASE (via Drizzle)
// ============================================================
const [existingUser] = await db.select()
.from(users)
.where(eq(users.clerkUserId, clerkId))
.limit(1);

if (existingUser) {
// Update existing user with latest Clerk data
await db.update(users)
.set({
email,
...(firstName && { firstName }),
...(lastName && { lastName }),
...(avatarUrl && { avatarUrl }),
})
.where(eq(users.clerkUserId, clerkId));
Comment on lines +75 to +82

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Avoid overwriting email with null on update.

email is set unconditionally in the update branch, while name/avatar are guarded. If a user.updated event arrives without a primary email, email becomes null, wiping the stored identity and breaking the email-based linking path (Line 87) and unique-identity assumptions. Guard it like the other fields.

Suggested change
         await db.update(users)
           .set({
-            email,
+            ...(email && { email }),
             ...(firstName && { firstName }),
             ...(lastName && { lastName }),
             ...(avatarUrl && { avatarUrl }),
           })
           .where(eq(users.clerkUserId, clerkId));
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
await db.update(users)
.set({
email,
...(firstName && { firstName }),
...(lastName && { lastName }),
...(avatarUrl && { avatarUrl }),
})
.where(eq(users.clerkUserId, clerkId));
await db.update(users)
.set({
...(email && { email }),
...(firstName && { firstName }),
...(lastName && { lastName }),
...(avatarUrl && { avatarUrl }),
})
.where(eq(users.clerkUserId, clerkId));
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/api/clerk/webhook/route.ts` around lines 75 - 82, The update branch in
the Clerk webhook handler is unconditionally writing `email`, which can
overwrite a stored value with `null` when `user.updated` has no primary email.
Update the `db.update(users).set(...)` logic in the webhook route so `email` is
only included when a real value exists, just like `firstName`, `lastName`, and
`avatarUrl`, keeping the existing identity needed by the email-linking flow and
`users.clerkUserId` update path.

} else {
// Check by email first to link existing Supabase-created user to Clerk
const [existingEmailUser] = await db.select()
.from(users)
.where(eq(users.clerkUserId, id))
.where(eq(users.email, email))
.limit(1);

if (existingUser) {
if (existingEmailUser) {
// Link existing user to this Clerk ID
await db.update(users)
.set({ email })
.where(eq(users.clerkUserId, id));
.set({ clerkUserId: clerkId })
.where(eq(users.id, existingEmailUser.id));
} else {
// Check by email first to link
const [existingEmailUser] = await db.select()
.from(users)
.where(eq(users.email, email))
.limit(1);

if (existingEmailUser) {
await db.update(users)
.set({ clerkUserId: id })
.where(eq(users.id, existingEmailUser.id));
} else {
await db.insert(users).values({
clerkUserId: id,
email,
role: 'viewer'
});
}
// Create new user record
await db.insert(users).values({
clerkUserId: clerkId,
email,
role: 'viewer',
...(firstName && { firstName }),
...(lastName && { lastName }),
...(avatarUrl && { avatarUrl }),
});
}
}

// ============================================================
// SYNC TO QCX-BACKEND SUPABASE (via RPC)
// This ensures the edge function and RLS policies in QCX-BACKEND
// have the correct clerk_user_id for authentication.
// ============================================================
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL
const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY

if (supabaseUrl && supabaseServiceKey) {
try {
const supabase = createClient(supabaseUrl, supabaseServiceKey)

// Call the sync_clerk_user RPC function in QCX-BACKEND
// This is the canonical way to create/link users across both systems
const { data: backendUserId, error: rpcError } = await supabase.rpc('sync_clerk_user', {
p_clerk_id: clerkId,
p_email: email,
p_first_name: firstName,
p_last_name: lastName,
p_avatar_url: avatarUrl,
})

if (rpcError) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When this RPC call fails, the handler logs and still returns HTTP 200. Clerk treats 2xx as success and won’t retry delivery, so transient Supabase failures can permanently leave clerk_user_id unsynced in QCX-BACKEND for that user.

Suggested fix: return non-2xx on backend sync failure (or enqueue a durable retry) so this critical sync path is eventually consistent.

console.error(`[Webhook] Failed to sync user ${clerkId} to QCX-BACKEND:`, rpcError)
} else {
console.log(`[Webhook] Successfully synced user ${clerkId} to QCX-BACKEND (UUID: ${backendUserId})`)
}
} catch (err) {
console.error(`[Webhook] Exception syncing user ${clerkId} to QCX-BACKEND:`, err)
// Don't fail the webhook — the app DB sync already succeeded
}
} else {
console.warn('[Webhook] Supabase credentials not configured, skipping QCX-BACKEND sync')
}
}
}

if (eventType === 'user.deleted') {
// Optionally handle user deletion
// For safety, we might not want to delete user data entirely
console.log('[Webhook] User deleted event received:', evt.data)
Comment on lines 145 to +148

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

Avoid logging the full user payload (PII).

evt.data for user.deleted contains PII (email, name, profile fields). Logging the entire payload persists personal data in application logs, a GDPR/CCPA retention concern. Log only the non-sensitive identifier.

🔒️ Proposed fix
   if (eventType === 'user.deleted') {
     // Optionally handle user deletion
     // For safety, we might not want to delete user data entirely
-    console.log('[Webhook] User deleted event received:', evt.data)
+    console.log('[Webhook] User deleted event received:', evt.data?.id)
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (eventType === 'user.deleted') {
// Optionally handle user deletion
// For safety, we might not want to delete user data entirely
console.log('[Webhook] User deleted event received:', evt.data)
if (eventType === 'user.deleted') {
// Optionally handle user deletion
// For safety, we might not want to delete user data entirely
console.log('[Webhook] User deleted event received:', evt.data?.id)
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/api/clerk/webhook/route.ts` around lines 145 - 148, The user.deleted
handler in the webhook route is logging the full evt.data payload, which
includes sensitive PII. Update the eventType === 'user.deleted' branch in the
route.ts handler to log only a non-sensitive identifier such as the Clerk user
id from evt.data, and avoid printing any profile or email fields in the
console.log.

}

return new Response('', { status: 200 })
Expand Down
16 changes: 11 additions & 5 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 4 additions & 7 deletions drizzle.config.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,15 @@
import type { Config } from 'drizzle-kit';
import * as dotenv from 'dotenv';

dotenv.config({ path: '.env.local' });

if (!process.env.DATABASE_URL) {
throw new Error('DATABASE_URL environment variable is not set');
const dbUrl = process.env.POSTGRES_URL;
if (!dbUrl) {
throw new Error('POSTGRES_URL is not set');
}

export default {
schema: './lib/db/schema.ts',
out: './drizzle/migrations',
dialect: 'postgresql',
dbCredentials: {
url: process.env.DATABASE_URL, // Changed from connectionString to url
url: dbUrl,
},
verbose: true,
strict: true,
Expand Down
Loading