Initial commit
This commit is contained in:
588
references/database-patterns.md
Normal file
588
references/database-patterns.md
Normal file
@@ -0,0 +1,588 @@
|
||||
# Database Patterns - CareBridge
|
||||
|
||||
## Overview
|
||||
|
||||
CareBridge uses Supabase (PostgreSQL) for data storage. Security is enforced via **Clerk authentication + Server Actions** for most tables, with RLS enabled only for specific tables (user_profiles, consultations, subscriptions).
|
||||
|
||||
## Database Schema Reference
|
||||
|
||||
### Auto-Generated Types (Single Source of Truth)
|
||||
|
||||
The complete database schema is auto-generated in **`src/lib/database.types.ts`**.
|
||||
|
||||
**To regenerate types after migrations:**
|
||||
|
||||
```bash
|
||||
# From linked remote project (recommended)
|
||||
npx supabase gen types typescript --linked > src/lib/database.types.ts
|
||||
|
||||
# From local dev database
|
||||
npx supabase gen types typescript --local > src/lib/database.types.ts
|
||||
|
||||
# From specific project ID
|
||||
npx supabase gen types typescript --project-id YOUR_PROJECT_ID > src/lib/database.types.ts
|
||||
```
|
||||
|
||||
**ALWAYS regenerate types after creating migrations!**
|
||||
|
||||
## Supabase Client Patterns
|
||||
|
||||
### Server-Side Client (Preferred)
|
||||
|
||||
Use in Server Components and Server Actions:
|
||||
|
||||
```typescript
|
||||
import { createServerClient } from '@/lib/supabase/server'
|
||||
|
||||
export default async function Page() {
|
||||
const supabase = await createServerClient()
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('cases')
|
||||
.select('*')
|
||||
.eq('owner_id', userId)
|
||||
|
||||
if (error) {
|
||||
console.error('Database error:', error)
|
||||
return <ErrorDisplay />
|
||||
}
|
||||
|
||||
return <CasesList cases={data} />
|
||||
}
|
||||
```
|
||||
|
||||
### Client-Side Client (When Needed)
|
||||
|
||||
Use in Client Components:
|
||||
|
||||
```typescript
|
||||
'use client'
|
||||
|
||||
import { createBrowserClient } from '@/lib/supabase/client'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
export function ClientComponent() {
|
||||
const [data, setData] = useState(null)
|
||||
const supabase = createBrowserClient()
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchData() {
|
||||
const { data } = await supabase.from('cases').select('*')
|
||||
setData(data)
|
||||
}
|
||||
fetchData()
|
||||
}, [])
|
||||
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
## Migrations
|
||||
|
||||
### Creating Migrations
|
||||
|
||||
**ALWAYS use Supabase CLI for schema changes:**
|
||||
|
||||
```bash
|
||||
# Create a new migration
|
||||
npx supabase migration new migration_name
|
||||
|
||||
# This creates: supabase/migrations/YYYYMMDD_migration_name.sql
|
||||
```
|
||||
|
||||
### Migration Structure
|
||||
|
||||
```sql
|
||||
-- Migration: 20251018_add_concierge_packages.sql
|
||||
|
||||
-- Create table
|
||||
CREATE TABLE IF NOT EXISTS concierge_packages (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
clerk_user_id TEXT NOT NULL,
|
||||
package_type VARCHAR(50) NOT NULL,
|
||||
price_paid_cents INT NOT NULL CHECK (price_paid_cents > 0),
|
||||
status VARCHAR(20) DEFAULT 'pending',
|
||||
created_at TIMESTAMPTZ DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
|
||||
-- Create indexes
|
||||
CREATE INDEX idx_concierge_packages_user
|
||||
ON concierge_packages(clerk_user_id);
|
||||
|
||||
CREATE INDEX idx_concierge_packages_status
|
||||
ON concierge_packages(status);
|
||||
|
||||
-- Add table comment
|
||||
COMMENT ON TABLE concierge_packages IS 'Stores concierge service package purchases. Security enforced by Clerk auth + Server Actions (RLS disabled)';
|
||||
|
||||
-- Create trigger for updated_at (if function exists)
|
||||
CREATE TRIGGER update_concierge_packages_updated_at
|
||||
BEFORE UPDATE ON concierge_packages
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
-- Add column comments
|
||||
COMMENT ON COLUMN concierge_packages.price_paid_cents IS 'Price paid in cents (e.g., 39900 = $399.00)';
|
||||
```
|
||||
|
||||
### Applying Migrations
|
||||
|
||||
```bash
|
||||
# Apply locally
|
||||
npx supabase db reset
|
||||
|
||||
# Apply to remote (production)
|
||||
npx supabase db push
|
||||
```
|
||||
|
||||
## Security Architecture
|
||||
|
||||
### CareBridge Uses Two Security Patterns:
|
||||
|
||||
**Pattern 1: Clerk Authentication + Server Actions (Most Tables)**
|
||||
|
||||
Most tables have RLS **disabled** and rely on:
|
||||
- Clerk authentication for user identity
|
||||
- Server Actions that enforce authorization logic
|
||||
- Server-side validation before database operations
|
||||
|
||||
```sql
|
||||
-- Example: Most tables use this pattern
|
||||
CREATE TABLE cases (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
owner_id TEXT NOT NULL,
|
||||
-- ... other columns
|
||||
);
|
||||
|
||||
-- RLS is DISABLED - security enforced in Server Actions
|
||||
COMMENT ON TABLE cases IS 'Security enforced by Clerk auth + Server Actions (RLS disabled)';
|
||||
```
|
||||
|
||||
**Pattern 2: Row-Level Security (Specific Tables Only)**
|
||||
|
||||
Only 3 tables use RLS:
|
||||
- `user_profiles` - User profile data
|
||||
- `consultations` - Consultation bookings
|
||||
- `subscriptions` - Subscription data
|
||||
|
||||
```sql
|
||||
-- Example: Tables with RLS enabled
|
||||
ALTER TABLE user_profiles ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY "users_own_profile"
|
||||
ON user_profiles
|
||||
FOR ALL
|
||||
USING (auth.jwt() ->> 'sub' = clerk_user_id);
|
||||
```
|
||||
|
||||
### When to Use Each Pattern
|
||||
|
||||
**Use Clerk + Server Actions (default):**
|
||||
- Complex authorization logic (team access, roles)
|
||||
- Multi-table operations requiring consistency
|
||||
- Business logic validation
|
||||
- Most application features
|
||||
|
||||
**Use RLS (rare):**
|
||||
- Simple user-owned resources
|
||||
- Direct client queries (rare in Next.js)
|
||||
- Additional security layer for sensitive data
|
||||
|
||||
### Server Action Security Pattern
|
||||
|
||||
```typescript
|
||||
// src/lib/actions/case-actions.ts
|
||||
'use server'
|
||||
|
||||
import { auth } from '@clerk/nextjs/server'
|
||||
import { createServerClient } from '@/lib/supabase/server'
|
||||
|
||||
export async function getCases() {
|
||||
const { userId } = await auth()
|
||||
|
||||
if (!userId) {
|
||||
throw new Error('Unauthorized')
|
||||
}
|
||||
|
||||
const supabase = await createServerClient()
|
||||
|
||||
// Security: Only fetch cases owned by authenticated user
|
||||
const { data, error } = await supabase
|
||||
.from('cases')
|
||||
.select('*')
|
||||
.eq('owner_id', userId)
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
}
|
||||
```
|
||||
|
||||
## Query Patterns
|
||||
|
||||
### Basic CRUD
|
||||
|
||||
```typescript
|
||||
// Create
|
||||
const { data, error } = await supabase
|
||||
.from('cases')
|
||||
.insert({
|
||||
owner_id: userId,
|
||||
care_recipient_name: 'John Doe',
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
// Read (single)
|
||||
const { data, error } = await supabase
|
||||
.from('cases')
|
||||
.select('*')
|
||||
.eq('id', caseId)
|
||||
.single()
|
||||
|
||||
// Read (multiple)
|
||||
const { data, error } = await supabase
|
||||
.from('cases')
|
||||
.select('*')
|
||||
.eq('owner_id', userId)
|
||||
|
||||
// Update
|
||||
const { data, error } = await supabase
|
||||
.from('cases')
|
||||
.update({ care_recipient_name: 'Jane Doe' })
|
||||
.eq('id', caseId)
|
||||
|
||||
// Delete
|
||||
const { data, error } = await supabase
|
||||
.from('cases')
|
||||
.delete()
|
||||
.eq('id', caseId)
|
||||
```
|
||||
|
||||
### Joins (Relations)
|
||||
|
||||
```typescript
|
||||
// Get cases with their members
|
||||
const { data, error } = await supabase
|
||||
.from('cases')
|
||||
.select(`
|
||||
*,
|
||||
case_members (
|
||||
id,
|
||||
user_id,
|
||||
role
|
||||
)
|
||||
`)
|
||||
.eq('owner_id', userId)
|
||||
```
|
||||
|
||||
### Filtering
|
||||
|
||||
```typescript
|
||||
// Equals
|
||||
.eq('status', 'active')
|
||||
|
||||
// Not equals
|
||||
.neq('status', 'deleted')
|
||||
|
||||
// Greater than
|
||||
.gt('created_at', '2024-01-01')
|
||||
|
||||
// In array
|
||||
.in('status', ['active', 'pending'])
|
||||
|
||||
// Is null
|
||||
.is('deleted_at', null)
|
||||
|
||||
// Like (case-insensitive)
|
||||
.ilike('name', '%john%')
|
||||
|
||||
// Order
|
||||
.order('created_at', { ascending: false })
|
||||
|
||||
// Limit
|
||||
.limit(10)
|
||||
|
||||
// Range (pagination)
|
||||
.range(0, 9) // First 10 items
|
||||
```
|
||||
|
||||
### Counting
|
||||
|
||||
```typescript
|
||||
const { count, error } = await supabase
|
||||
.from('cases')
|
||||
.select('*', { count: 'exact', head: true })
|
||||
.eq('owner_id', userId)
|
||||
```
|
||||
|
||||
### Upsert (Insert or Update)
|
||||
|
||||
```typescript
|
||||
const { data, error } = await supabase
|
||||
.from('subscriptions')
|
||||
.upsert({
|
||||
clerk_user_id: userId,
|
||||
is_active: true,
|
||||
})
|
||||
.select()
|
||||
```
|
||||
|
||||
## Transaction Patterns
|
||||
|
||||
### Using RPC for Transactions
|
||||
|
||||
Create a database function:
|
||||
|
||||
```sql
|
||||
-- supabase/migrations/20251018_create_case_with_member.sql
|
||||
CREATE OR REPLACE FUNCTION create_case_with_member(
|
||||
p_owner_id TEXT,
|
||||
p_case_name TEXT
|
||||
) RETURNS UUID AS $$
|
||||
DECLARE
|
||||
v_case_id UUID;
|
||||
BEGIN
|
||||
-- Insert case
|
||||
INSERT INTO cases (owner_id, care_recipient_name)
|
||||
VALUES (p_owner_id, p_case_name)
|
||||
RETURNING id INTO v_case_id;
|
||||
|
||||
-- Insert owner as member
|
||||
INSERT INTO case_members (case_id, user_id, role)
|
||||
VALUES (v_case_id, p_owner_id, 'owner');
|
||||
|
||||
RETURN v_case_id;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
```
|
||||
|
||||
Call from TypeScript:
|
||||
|
||||
```typescript
|
||||
const { data, error } = await supabase.rpc('create_case_with_member', {
|
||||
p_owner_id: userId,
|
||||
p_case_name: 'John Doe',
|
||||
})
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
```typescript
|
||||
const { data, error } = await supabase
|
||||
.from('cases')
|
||||
.select('*')
|
||||
.eq('id', caseId)
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
console.error('Database error:', error)
|
||||
|
||||
// Check for specific error codes
|
||||
if (error.code === 'PGRST116') {
|
||||
// Not found
|
||||
return { error: 'Case not found' }
|
||||
}
|
||||
|
||||
return { error: 'Failed to fetch case' }
|
||||
}
|
||||
|
||||
return { data }
|
||||
```
|
||||
|
||||
## Type Safety with Auto-Generated Types
|
||||
|
||||
CareBridge uses auto-generated types from `src/lib/database.types.ts`:
|
||||
|
||||
```typescript
|
||||
import { Database } from '@/lib/database.types'
|
||||
|
||||
// Extract table types
|
||||
type Case = Database['public']['Tables']['cases']['Row']
|
||||
type CaseInsert = Database['public']['Tables']['cases']['Insert']
|
||||
type CaseUpdate = Database['public']['Tables']['cases']['Update']
|
||||
|
||||
// Use with Supabase client
|
||||
const supabase = createServerClient<Database>()
|
||||
|
||||
// Typed queries
|
||||
const { data, error } = await supabase
|
||||
.from('cases') // ✅ Autocomplete for table names
|
||||
.select('*') // ✅ Typed return value
|
||||
.eq('id', caseId)
|
||||
```
|
||||
|
||||
**Remember to regenerate types after migrations:**
|
||||
|
||||
```bash
|
||||
npx supabase gen types typescript --linked > src/lib/database.types.ts
|
||||
```
|
||||
|
||||
## Realtime Subscriptions
|
||||
|
||||
```typescript
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { createBrowserClient } from '@/lib/supabase/client'
|
||||
|
||||
export function RealtimeComponent() {
|
||||
const supabase = createBrowserClient()
|
||||
|
||||
useEffect(() => {
|
||||
const channel = supabase
|
||||
.channel('cases-changes')
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{
|
||||
event: '*',
|
||||
schema: 'public',
|
||||
table: 'cases',
|
||||
},
|
||||
(payload) => {
|
||||
console.log('Change received!', payload)
|
||||
// Update UI
|
||||
}
|
||||
)
|
||||
.subscribe()
|
||||
|
||||
return () => {
|
||||
supabase.removeChannel(channel)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return <div>Listening for changes...</div>
|
||||
}
|
||||
```
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
### ❌ Mistake #1: Not checking for errors
|
||||
|
||||
```typescript
|
||||
// WRONG - No error handling
|
||||
const { data } = await supabase.from('cases').select('*')
|
||||
return data // Could be null!
|
||||
|
||||
// CORRECT
|
||||
const { data, error } = await supabase.from('cases').select('*')
|
||||
|
||||
if (error) {
|
||||
console.error('Error:', error)
|
||||
return { error: error.message }
|
||||
}
|
||||
|
||||
return { data }
|
||||
```
|
||||
|
||||
### ❌ Mistake #2: Using client in server components
|
||||
|
||||
```typescript
|
||||
// WRONG - Don't import client in server components
|
||||
import { createBrowserClient } from '@/lib/supabase/client'
|
||||
|
||||
export default async function Page() {
|
||||
const supabase = createBrowserClient() // ERROR!
|
||||
}
|
||||
|
||||
// CORRECT - Use server client
|
||||
import { createServerClient } from '@/lib/supabase/server'
|
||||
|
||||
export default async function Page() {
|
||||
const supabase = await createServerClient()
|
||||
}
|
||||
```
|
||||
|
||||
### ❌ Mistake #3: Not securing Server Actions
|
||||
|
||||
```typescript
|
||||
// WRONG - No authentication check
|
||||
'use server'
|
||||
|
||||
export async function deleteCase(caseId: string) {
|
||||
const supabase = await createServerClient()
|
||||
await supabase.from('cases').delete().eq('id', caseId)
|
||||
// Anyone can delete any case!
|
||||
}
|
||||
|
||||
// CORRECT - Verify user owns the case
|
||||
'use server'
|
||||
|
||||
import { auth } from '@clerk/nextjs/server'
|
||||
|
||||
export async function deleteCase(caseId: string) {
|
||||
const { userId } = await auth()
|
||||
|
||||
if (!userId) {
|
||||
throw new Error('Unauthorized')
|
||||
}
|
||||
|
||||
const supabase = await createServerClient()
|
||||
|
||||
// Verify ownership before deleting
|
||||
const { data: caseData } = await supabase
|
||||
.from('cases')
|
||||
.select('owner_id')
|
||||
.eq('id', caseId)
|
||||
.single()
|
||||
|
||||
if (caseData?.owner_id !== userId) {
|
||||
throw new Error('Unauthorized')
|
||||
}
|
||||
|
||||
await supabase.from('cases').delete().eq('id', caseId)
|
||||
}
|
||||
```
|
||||
|
||||
### ❌ Mistake #4: Not using indexes
|
||||
|
||||
```sql
|
||||
-- WRONG - Frequently queried column without index
|
||||
CREATE TABLE cases (
|
||||
id UUID PRIMARY KEY,
|
||||
owner_id TEXT NOT NULL -- No index!
|
||||
);
|
||||
|
||||
-- CORRECT - Add indexes for frequently queried columns
|
||||
CREATE TABLE cases (
|
||||
id UUID PRIMARY KEY,
|
||||
owner_id TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX idx_cases_owner ON cases(owner_id);
|
||||
```
|
||||
|
||||
### ❌ Mistake #5: Not using migrations
|
||||
|
||||
```typescript
|
||||
// WRONG - Creating tables via SQL in application code
|
||||
await supabase.sql`CREATE TABLE ...` // Don't do this!
|
||||
|
||||
// CORRECT - Use migrations
|
||||
// Create: supabase/migrations/20251018_create_table.sql
|
||||
// Then run: npx supabase db push
|
||||
```
|
||||
|
||||
## Performance Tips
|
||||
|
||||
1. **Use indexes** for frequently queried columns
|
||||
2. **Select specific columns** instead of `select('*')`
|
||||
3. **Use pagination** with `.range()` for large datasets
|
||||
4. **Batch operations** when possible
|
||||
5. **Use RPC functions** for complex operations
|
||||
6. **Cache results** when appropriate
|
||||
|
||||
## Migration Checklist
|
||||
|
||||
When creating a migration:
|
||||
|
||||
- [ ] Use `npx supabase migration new name`
|
||||
- [ ] Include `IF NOT EXISTS` clauses
|
||||
- [ ] Add appropriate indexes for frequently queried columns
|
||||
- [ ] Add table comment indicating security pattern (RLS vs Clerk + Server Actions)
|
||||
- [ ] Only enable RLS if needed (most tables use Clerk + Server Actions)
|
||||
- [ ] Add `updated_at` trigger if needed
|
||||
- [ ] Add column comments for documentation
|
||||
- [ ] Test locally with `npx supabase db reset`
|
||||
- [ ] Push to production with `npx supabase db push`
|
||||
- [ ] **Regenerate TypeScript types:** `npx supabase gen types typescript --linked > src/lib/database.types.ts`
|
||||
Reference in New Issue
Block a user