Initial commit

This commit is contained in:
Zhongwei Li
2025-11-29 18:47:35 +08:00
commit b90acffddf
9 changed files with 2356 additions and 0 deletions

189
references/case-context.md Normal file
View File

@@ -0,0 +1,189 @@
# Case Context Management - CareBridge
## Overview
CareBridge is a multi-case management system where users can manage multiple care recipients. The `caseId` is the primary context identifier that must be preserved across ALL navigation.
## Critical Rule
**NEVER navigate to a page without preserving the current `caseId`.**
## How Case Context Works
### URL Structure
All protected routes must support the `caseId` query parameter:
```
/dashboard?caseId=uuid-here
/calendar?caseId=uuid-here
/subscriptions?caseId=uuid-here
/case-settings?caseId=uuid-here
```
### Reading Case Context
Use the helper function to safely extract `caseId`:
```typescript
// In Server Components (pages)
import { getCaseIdFromParams } from '@/lib/case-context'
type SearchParams = Promise<{ caseId?: string }>
export default async function MyPage({
searchParams,
}: {
searchParams: SearchParams
}) {
const params = await searchParams // Next.js 15 requirement
const caseId = getCaseIdFromParams(params)
if (!caseId) {
// Show "No case selected" empty state
return <EmptyState />
}
// Use caseId for data fetching
const data = await getData(caseId)
// ...
}
```
```typescript
// In Client Components
'use client'
import { useSearchParams } from 'next/navigation'
import { useState, useEffect } from 'react'
export function MyClientComponent() {
const searchParams = useSearchParams()
const [caseId, setCaseId] = useState<string | null>(null)
// Prevent hydration mismatch
useEffect(() => {
setCaseId(searchParams.get('caseId'))
}, [searchParams])
if (!caseId) return null
// Use caseId
}
```
### Preserving Case Context in Navigation
**All navigation links MUST preserve caseId:**
```typescript
// ✅ CORRECT - Preserves caseId
import { useSearchParams } from 'next/navigation'
import Link from 'next/link'
const searchParams = useSearchParams()
const caseId = searchParams.get('caseId')
<Link href={caseId ? `/dashboard?caseId=${caseId}` : '/dashboard'}>
Dashboard
</Link>
// ❌ WRONG - Loses case context
<Link href="/dashboard">
Dashboard
</Link>
```
### Navigation Component Pattern
See `src/components/nav-main.tsx` for the correct pattern:
```typescript
'use client'
export function NavMain({ items }) {
const searchParams = useSearchParams()
const [caseId, setCaseId] = useState<string | null>(null)
useEffect(() => {
setCaseId(searchParams.get('caseId'))
}, [searchParams])
return items.map((item) => {
// Preserve caseId in ALL navigation
const url = caseId ? `${item.url}?caseId=${caseId}` : item.url
return <Link href={url}>{item.title}</Link>
})
}
```
## Case Switching
Case switching happens in the sidebar via `CaseSwitcher` component. When a user selects a different case:
1. The URL updates with the new `caseId`
2. All navigation automatically preserves the new `caseId`
3. The page re-renders with new case data
## Empty States
When `caseId` is missing, show a helpful empty state:
```typescript
if (!caseId) {
return (
<EmptyState
icon={Users}
title="No Case Selected"
description="Select a case from the sidebar to continue"
/>
)
}
```
## Common Mistakes
### ❌ Mistake #1: Forgetting to preserve caseId in links
```typescript
// WRONG - Will break case context
<Link href="/subscriptions">Subscribe</Link>
```
### ❌ Mistake #2: Not handling Next.js 15 async params
```typescript
// WRONG - In Next.js 15, params are async
export default function Page({ searchParams }) {
const caseId = searchParams.caseId // ERROR!
}
// CORRECT
export default async function Page({ searchParams }) {
const params = await searchParams
const caseId = getCaseIdFromParams(params)
}
```
### ❌ Mistake #3: Using router.push without caseId
```typescript
// WRONG
router.push('/dashboard')
// CORRECT
const caseId = searchParams.get('caseId')
router.push(caseId ? `/dashboard?caseId=${caseId}` : '/dashboard')
```
## Testing Case Context
When testing a feature:
1. Navigate to a page with a caseId
2. Click any link/button that navigates
3. Verify the URL still has `?caseId=...`
4. Repeat for all navigation paths
If caseId is lost, you'll see the "No Case Selected" empty state - this indicates broken context preservation.

View File

@@ -0,0 +1,435 @@
# Component Standards - CareBridge
## Critical Rule: No Custom Components
**NEVER create custom UI components from scratch. ALWAYS use shadcn/ui.**
## Using shadcn/ui
### Adding Components
```bash
# List available components
npx shadcn@latest add
# Add a specific component
npx shadcn@latest add button
npx shadcn@latest add card
npx shadcn@latest add dialog
npx shadcn@latest add form
```
This adds components to `src/components/ui/` with proper TypeScript types and styling.
### Example: Adding a Button
```bash
npx shadcn@latest add button
```
Then use it:
```typescript
import { Button } from '@/components/ui/button'
export function MyComponent() {
return <Button variant="default">Click me</Button>
}
```
### Available Variants
shadcn components come with built-in variants. Check the component file for options:
```typescript
// Button variants
<Button variant="default">Default</Button>
<Button variant="destructive">Delete</Button>
<Button variant="outline">Outline</Button>
<Button variant="secondary">Secondary</Button>
<Button variant="ghost">Ghost</Button>
<Button variant="link">Link</Button>
// Card components
<Card>
<CardHeader>
<CardTitle>Title</CardTitle>
<CardDescription>Description</CardDescription>
</CardHeader>
<CardContent>
Content here
</CardContent>
<CardFooter>
Footer actions
</CardFooter>
</Card>
```
## Component Organization
### Location
```
src/
├── components/
│ ├── ui/ # shadcn components (DON'T EDIT)
│ │ ├── button.tsx
│ │ ├── card.tsx
│ │ └── ...
│ ├── dashboard/ # Dashboard-specific components
│ ├── calendar/ # Calendar-specific components
│ ├── concierge/ # Concierge-specific components
│ └── nav-main.tsx # Global navigation components
```
### Naming Conventions
- UI components: `kebab-case.tsx` (button.tsx, card.tsx)
- Feature components: `PascalCase.tsx` (DashboardHeader.tsx, CaseList.tsx)
- Use descriptive names: `ConciergeBookingForm.tsx` not `Form.tsx`
## Styling Rules
### CSS Variables (READ-ONLY)
**NEVER create custom CSS variables. ONLY use existing ones from `globals.css`.**
Available CSS variables:
```css
/* From globals.css */
--background
--foreground
--card
--card-foreground
--popover
--popover-foreground
--primary
--primary-foreground
--secondary
--secondary-foreground
--muted
--muted-foreground
--accent
--accent-foreground
--destructive
--destructive-foreground
--border
--input
--ring
```
### Using CSS Variables
```typescript
// ✅ CORRECT - Use existing variables
<div className="bg-background text-foreground">
Content
</div>
// ✅ CORRECT - Use Tailwind utility classes
<div className="bg-primary text-primary-foreground rounded-lg p-4">
Card content
</div>
// ❌ WRONG - Custom CSS variables
<div style={{ backgroundColor: 'var(--my-custom-color)' }}>
Content
</div>
// ❌ WRONG - Inline styles
<div style={{ backgroundColor: '#3498db', padding: '16px' }}>
Content
</div>
```
### Tailwind Classes
Use Tailwind utility classes for styling:
```typescript
// ✅ CORRECT
<div className="flex items-center gap-4 p-6 rounded-lg border">
<div className="flex-1">Content</div>
<Button variant="default">Action</Button>
</div>
// ❌ WRONG - Custom CSS classes
<div className="my-custom-container">
Content
</div>
```
## Form Components
### Using React Hook Form + Zod
CareBridge uses React Hook Form with Zod validation:
```typescript
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { Form, FormField, FormItem, FormLabel, FormControl, FormMessage } from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
const formSchema = z.object({
name: z.string().min(1, 'Name is required'),
email: z.string().email('Invalid email'),
})
export function MyForm() {
const form = useForm({
resolver: zodResolver(formSchema),
defaultValues: {
name: '',
email: '',
},
})
const onSubmit = async (values: z.infer<typeof formSchema>) => {
// Handle submission
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit">Submit</Button>
</form>
</Form>
)
}
```
## Dialog/Modal Pattern
```typescript
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
export function MyDialog() {
return (
<Dialog>
<DialogTrigger asChild>
<Button>Open Dialog</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Dialog Title</DialogTitle>
</DialogHeader>
<div>Dialog content here</div>
</DialogContent>
</Dialog>
)
}
```
## Empty States Pattern
Use the `EmptyState` component for "no data" scenarios:
```typescript
import { EmptyState } from '@/components/dashboard/empty-state'
import { Users } from 'lucide-react'
export function MyComponent({ data }) {
if (!data || data.length === 0) {
return (
<EmptyState
icon={Users}
title="No Cases Found"
description="Create your first care case to get started"
/>
)
}
return <div>{/* Render data */}</div>
}
```
## Loading States
```typescript
import { Skeleton } from '@/components/ui/skeleton'
export function LoadingState() {
return (
<div className="space-y-4">
<Skeleton className="h-12 w-full" />
<Skeleton className="h-12 w-full" />
<Skeleton className="h-12 w-full" />
</div>
)
}
```
## Icons
Use Lucide React for icons:
```typescript
import { Calendar, User, Settings, CreditCard } from 'lucide-react'
export function MyComponent() {
return (
<div className="flex items-center gap-2">
<Calendar className="h-4 w-4" />
<span>Calendar</span>
</div>
)
}
```
## Common Component Patterns
### Card with Actions
```typescript
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>Title</CardTitle>
<Button variant="ghost" size="sm">
<Settings className="h-4 w-4" />
</Button>
</div>
<CardDescription>Description</CardDescription>
</CardHeader>
<CardContent>
Content here
</CardContent>
<CardFooter className="flex justify-end gap-2">
<Button variant="outline">Cancel</Button>
<Button>Save</Button>
</CardFooter>
</Card>
```
### List with Items
```typescript
<div className="space-y-2">
{items.map((item) => (
<Card key={item.id}>
<CardContent className="flex items-center justify-between p-4">
<div>
<h3 className="font-medium">{item.title}</h3>
<p className="text-sm text-muted-foreground">{item.description}</p>
</div>
<Button variant="ghost" size="sm">
View
</Button>
</CardContent>
</Card>
))}
</div>
```
## Responsive Design
Use Tailwind responsive prefixes:
```typescript
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{/* Responsive grid */}
</div>
<div className="flex flex-col sm:flex-row gap-4">
{/* Stack vertically on mobile, horizontally on larger screens */}
</div>
```
## Common Mistakes
### ❌ Mistake #1: Creating custom components
```typescript
// WRONG - Don't create custom buttons
export function CustomButton({ children }) {
return (
<button className="custom-btn">
{children}
</button>
)
}
// CORRECT - Use shadcn button
import { Button } from '@/components/ui/button'
<Button variant="default">{children}</Button>
```
### ❌ Mistake #2: Custom CSS variables
```typescript
// WRONG - Don't create custom variables
<div style={{ color: 'var(--my-custom-color)' }}>
Content
</div>
// CORRECT - Use existing variables
<div className="text-primary">
Content
</div>
```
### ❌ Mistake #3: Inline styles
```typescript
// WRONG - Avoid inline styles
<div style={{ padding: '16px', margin: '8px' }}>
Content
</div>
// CORRECT - Use Tailwind classes
<div className="p-4 m-2">
Content
</div>
```
### ❌ Mistake #4: Not using TypeScript types
```typescript
// WRONG - No types
export function MyComponent({ user }) {
return <div>{user.name}</div>
}
// CORRECT - Proper TypeScript types
type Props = {
user: {
name: string
email: string
}
}
export function MyComponent({ user }: Props) {
return <div>{user.name}</div>
}
```
## Component Checklist
Before creating a component:
- [ ] Is there a shadcn component for this? (Use `npx shadcn@latest add`)
- [ ] Am I using existing CSS variables?
- [ ] Am I using Tailwind classes instead of inline styles?
- [ ] Do I have proper TypeScript types?
- [ ] Is the component in the right directory?
- [ ] Am I preserving case context in navigation?

View 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`

View File

@@ -0,0 +1,446 @@
# Next.js 15 Patterns - CareBridge
## Critical Changes from Next.js 14
Next.js 15 introduces async Request APIs. Several patterns have changed.
## Async Params (CRITICAL)
### In App Router Pages
**ALL dynamic route params must be awaited**
```typescript
// ❌ WRONG - Next.js 14 pattern (will error in 15)
export default function Page({ params }: { params: { id: string } }) {
const { id } = params // ERROR!
}
// ✅ CORRECT - Next.js 15 pattern
type Props = {
params: Promise<{ id: string }>
}
export default async function Page({ params }: Props) {
const { id } = await params
// Use id
}
```
### In API Routes
**ALL dynamic route params must be awaited**
```typescript
// ❌ WRONG
export async function GET(
request: Request,
{ params }: { params: { userId: string } }
) {
const { userId } = params // ERROR!
}
// ✅ CORRECT
export async function GET(
request: Request,
{ params }: { params: Promise<{ userId: string }> }
) {
const { userId } = await params
// Use userId
}
```
## Async SearchParams
**SearchParams are also async in Next.js 15**
```typescript
// ❌ WRONG
export default function Page({
searchParams,
}: {
searchParams: { caseId?: string }
}) {
const caseId = searchParams.caseId // ERROR!
}
// ✅ CORRECT
type SearchParams = Promise<{ caseId?: string }>
export default async function Page({
searchParams,
}: {
searchParams: SearchParams
}) {
const params = await searchParams
const caseId = getCaseIdFromParams(params)
}
```
## Server Components vs Client Components
### When to Use Server Components (Default)
Use server components (no `'use client'`) for:
- Pages that fetch data
- Pages that need SEO
- Static content
- Database queries
```typescript
// Server Component (default)
import { createServerClient } from '@/lib/supabase/server'
export default async function DashboardPage() {
const supabase = await createServerClient()
const { data } = await supabase.from('cases').select('*')
return <div>{/* Render data */}</div>
}
```
### When to Use Client Components
Use `'use client'` for:
- Interactive UI (onClick, onChange, etc.)
- React hooks (useState, useEffect, useContext)
- Browser APIs (localStorage, window, etc.)
- Third-party libraries that need client-side
```typescript
'use client'
import { useState } from 'react'
export function InteractiveComponent() {
const [count, setCount] = useState(0)
return <button onClick={() => setCount(count + 1)}>{count}</button>
}
```
### Mixing Server and Client
**Pattern**: Server component fetches data, passes to client component
```typescript
// app/page.tsx (Server Component)
import { ClientComponent } from './client-component'
export default async function Page() {
const data = await fetchData()
return <ClientComponent data={data} />
}
// client-component.tsx (Client Component)
'use client'
export function ClientComponent({ data }) {
const [selected, setSelected] = useState(null)
return (
<div onClick={() => setSelected(data)}>
{/* Interactive UI */}
</div>
)
}
```
## Metadata API
Use the Metadata API for SEO:
```typescript
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: 'Dashboard | CareBridge',
description: 'Manage your care cases',
}
export default function Page() {
return <div>Dashboard</div>
}
```
## Loading and Error States
### Loading States
Create `loading.tsx` next to `page.tsx`:
```typescript
// app/dashboard/loading.tsx
export default function Loading() {
return <div>Loading dashboard...</div>
}
```
### Error Boundaries
Create `error.tsx` next to `page.tsx`:
```typescript
// app/dashboard/error.tsx
'use client' // Error boundaries must be client components
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
return (
<div>
<h2>Something went wrong!</h2>
<button onClick={reset}>Try again</button>
</div>
)
}
```
## Data Fetching Patterns
### Server-Side Fetching (Preferred)
```typescript
// In Server Component
export default async function Page() {
const supabase = await createServerClient()
const { data } = await supabase.from('cases').select('*')
return <CasesList cases={data} />
}
```
### Client-Side Fetching (When Needed)
```typescript
'use client'
import { useEffect, useState } from 'react'
export function ClientDataFetcher() {
const [data, setData] = useState(null)
useEffect(() => {
fetch('/api/data')
.then((res) => res.json())
.then(setData)
}, [])
if (!data) return <div>Loading...</div>
return <div>{/* Render data */}</div>
}
```
## Form Actions
Use Server Actions for forms:
```typescript
// app/actions.ts
'use server'
export async function createCase(formData: FormData) {
const name = formData.get('name')
const supabase = await createServerClient()
await supabase.from('cases').insert({ name })
revalidatePath('/dashboard')
}
// app/page.tsx
import { createCase } from './actions'
export default function Page() {
return (
<form action={createCase}>
<input name="name" />
<button type="submit">Create</button>
</form>
)
}
```
## Common Mistakes
### ❌ Mistake #1: Forgetting 'use client'
```typescript
// WRONG - Will error because useState needs client
import { useState } from 'react'
export function Component() {
const [count, setCount] = useState(0) // ERROR!
}
// CORRECT
'use client'
import { useState } from 'react'
export function Component() {
const [count, setCount] = useState(0)
}
```
### ❌ Mistake #2: Not awaiting params
```typescript
// WRONG
export default function Page({ params }) {
const { id } = params // ERROR in Next.js 15!
}
// CORRECT
export default async function Page({ params }) {
const { id } = await params
}
```
### ❌ Mistake #3: Using client-side hooks in server components
```typescript
// WRONG
export default function Page() {
const router = useRouter() // ERROR!
}
// CORRECT - Add 'use client'
'use client'
export default function Page() {
const router = useRouter()
}
```
### ❌ Mistake #4: Importing server-only code in client components
```typescript
// WRONG - Client component trying to use server-only code
'use client'
import { createServerClient } from '@/lib/supabase/server' // ERROR!
export function Component() {
const supabase = await createServerClient() // Won't work!
}
// CORRECT - Use Server Action instead
'use client'
import { getData } from './actions'
export function Component() {
const handleClick = async () => {
const data = await getData() // Calls server action
}
}
```
## Environment Variables
### Public vs Private
```typescript
// ✅ Public (available in browser)
const key = process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY
// ✅ Private (server-only)
const secret = process.env.STRIPE_SECRET_KEY // Only works in server components/actions
```
### Accessing in Client Components
```typescript
// ❌ WRONG - Private env vars don't work in client
'use client'
const secret = process.env.STRIPE_SECRET_KEY // undefined!
// ✅ CORRECT - Use public env vars
'use client'
const publicKey = process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY
```
## Caching
Next.js 15 has granular caching:
```typescript
// Cache indefinitely (static data)
const data = await fetch('https://api.example.com/data', {
cache: 'force-cache',
})
// Revalidate every hour
const data = await fetch('https://api.example.com/data', {
next: { revalidate: 3600 },
})
// Never cache (dynamic data)
const data = await fetch('https://api.example.com/data', {
cache: 'no-store',
})
```
## Route Handlers (API Routes)
```typescript
// app/api/route.ts
export async function GET(request: Request) {
const { searchParams } = new URL(request.url)
const id = searchParams.get('id')
return Response.json({ id })
}
export async function POST(request: Request) {
const body = await request.json()
return Response.json({ success: true })
}
```
## Dynamic Routes
```typescript
// app/cases/[caseId]/page.tsx
type Props = {
params: Promise<{ caseId: string }>
}
export default async function CasePage({ params }: Props) {
const { caseId } = await params
return <div>Case: {caseId}</div>
}
```
## Middleware
Use for auth checks, redirects, etc.:
```typescript
// middleware.ts
import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server'
const isProtectedRoute = createRouteMatcher([
'/dashboard(.*)',
'/cases(.*)',
])
export default clerkMiddleware((auth, req) => {
if (isProtectedRoute(req)) auth().protect()
})
export const config = {
matcher: ['/((?!.*\\..*|_next).*)', '/', '/(api|trpc)(.*)'],
}
```

View File

@@ -0,0 +1,331 @@
# Stripe Integration - CareBridge
## Overview
CareBridge uses Stripe for:
- **SaaS Subscription**: $19.99/month platform access
- **Concierge Packages**: 4 service packages (Essentials, Benefits, Concierge Plus, White-Glove)
## Architecture
### Pricing Configuration (Single Source of Truth)
**ALL pricing lives in `src/lib/config/concierge-pricing.ts`**
```typescript
export const CONCIERGE_PACKAGES: Record<PackageType, ConciergePackage> = {
essentials: {
name: 'Essentials Package',
priceRange: '$399 $899',
payment_type: 'one_time',
pricing: {
type: 'range', // Allows any price in range
min: 39900, // $399 in cents
max: 89900 // $899 in cents
}
},
concierge_plus: {
name: 'Concierge Plus',
priceRange: '$249 $499/month',
payment_type: 'subscription',
pricing: {
type: 'tiers', // Predefined Stripe price IDs
tiers: [
{
name: 'Light Support',
price: 24900,
priceId: process.env.NEXT_PUBLIC_STRIPE_CONCIERGE_PLUS_LIGHT_PRICE_ID,
},
// ... more tiers
]
}
}
}
```
### Flexible Pricing System
CareBridge supports two pricing approaches:
1. **Tiered Pricing** (Concierge Plus, White-Glove)
- Predefined Stripe price IDs
- User selects from available tiers
- Uses Stripe's price objects
2. **Range Pricing** (Essentials, Benefits)
- Custom pricing within a range
- Uses Stripe `price_data` for dynamic pricing
- Staff can set exact price during booking
## Stripe Product Setup
### Automated Setup Script
```bash
cd scripts
./setup-carebridge-pricing.sh
```
This creates:
- SaaS subscription product ($19.99/mo)
- Concierge Plus tiers (3 prices)
- White-Glove tiers (3 prices)
- Test customer
- Generates .env.stripe with all IDs
### Environment Variables
Required in `.env.local`:
```bash
# Stripe Keys
STRIPE_SECRET_KEY=sk_test_...
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
# SaaS Subscription
NEXT_PUBLIC_STRIPE_SAAS_MONTHLY_PRICE_ID=price_...
# Concierge Plus Tiers
NEXT_PUBLIC_STRIPE_CONCIERGE_PLUS_LIGHT_PRICE_ID=price_...
NEXT_PUBLIC_STRIPE_CONCIERGE_PLUS_STANDARD_PRICE_ID=price_...
NEXT_PUBLIC_STRIPE_CONCIERGE_PLUS_PREMIUM_PRICE_ID=price_...
# White-Glove Tiers
NEXT_PUBLIC_STRIPE_WHITE_GLOVE_STANDARD_PRICE_ID=price_...
NEXT_PUBLIC_STRIPE_WHITE_GLOVE_PREMIUM_PRICE_ID=price_...
NEXT_PUBLIC_STRIPE_WHITE_GLOVE_ENTERPRISE_PRICE_ID=price_...
```
## Creating Checkout Sessions
### SaaS Subscription
```typescript
// Fixed price - no user input needed
const response = await fetch('/api/create-subscription-checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ caseId }), // Preserve case context
})
```
API route automatically uses the SaaS price ID from env vars.
### Concierge Packages
```typescript
import { createConciergeCheckout } from '@/lib/actions/concierge-actions'
// For tiered pricing (Concierge Plus, White-Glove)
const result = await createConciergeCheckout({
package_type: 'concierge_plus',
price_cents: 24900, // Must match a tier price
display_name: 'Light Support',
case_id: caseId,
})
// For range pricing (Essentials, Benefits)
const result = await createConciergeCheckout({
package_type: 'essentials',
price_cents: 50000, // Any value between min/max
display_name: 'Essentials Package',
case_id: caseId,
})
```
The action automatically:
- Validates price is within allowed range/tiers
- Creates Stripe checkout with correct mode (subscription vs payment)
- Adds metadata for webhook routing
- Returns checkout URL
## Webhook Handling
**File**: `src/app/api/webhooks/stripe/route.ts`
### Metadata-Driven Routing
Webhooks determine the type by checking `session.metadata.package_type`:
```typescript
case 'checkout.session.completed': {
if (session.metadata?.package_type) {
// Concierge package purchase
await handleConciergeCheckout(session)
} else if (session.subscription) {
// SaaS subscription
await handleSaaSSubscriptionCheckout(session)
}
break
}
```
### Required Metadata
Always include in checkout sessions:
```typescript
metadata: {
clerk_user_id: userId,
package_type: 'concierge_plus', // For concierge packages
payment_type: 'subscription', // one_time, project_based, or subscription
price_display_name: 'Light Support',
}
```
### Webhook Events
Handle these events:
- `checkout.session.completed` - Create subscription/package record
- `customer.subscription.updated` - Update subscription status
- `customer.subscription.deleted` - Mark as cancelled
## Database Structure
### SaaS Subscriptions
Table: `subscriptions`
```sql
CREATE TABLE subscriptions (
id UUID PRIMARY KEY,
clerk_user_id TEXT NOT NULL,
stripe_subscription_id VARCHAR(255),
is_active BOOLEAN DEFAULT false, -- Simple boolean, no tiers
status VARCHAR(50),
current_period_end TIMESTAMPTZ,
-- ...
)
```
### Concierge Packages
Table: `concierge_packages`
```sql
CREATE TABLE concierge_packages (
id UUID PRIMARY KEY,
clerk_user_id TEXT NOT NULL,
package_type VARCHAR(50) NOT NULL, -- essentials, benefits, etc.
payment_type VARCHAR(20) NOT NULL, -- one_time, project_based, subscription
stripe_payment_intent_id VARCHAR(255), -- For one-time
stripe_subscription_id VARCHAR(255), -- For subscriptions
price_paid_cents INT NOT NULL,
price_display_name VARCHAR(100),
status VARCHAR(20) DEFAULT 'pending',
-- ...
)
```
## Testing
### Local Webhook Testing
```bash
# Terminal 1: Start webhook listener
stripe listen --forward-to localhost:3000/api/webhooks/stripe
# Copy the webhook secret (whsec_...) to .env.local as STRIPE_WEBHOOK_SECRET
# Terminal 2: Start dev server
npm run dev
```
### Test Cards
- Success: `4242 4242 4242 4242`
- Requires Auth: `4000 0025 0000 3155`
- Declined: `4000 0000 0000 9995`
Expiry: Any future date | CVC: Any 3 digits | ZIP: Any 5 digits
### Testing Checklist
1. ✅ SaaS subscription checkout
2. ✅ Concierge package with tiers
3. ✅ Concierge package with custom price
4. ✅ Webhook creates database records
5. ✅ Success page displays correctly
6. ✅ Billing page shows all subscriptions
## Common Mistakes
### ❌ Mistake #1: Not validating price
```typescript
// WRONG - No validation
await createConciergeCheckout({
price_cents: 1000000, // Way above max!
})
// CORRECT - Use the action, it validates automatically
const result = await createConciergeCheckout({
package_type: 'essentials',
price_cents: 50000,
display_name: 'Essentials',
})
if (!result.success) {
// Handle validation error
}
```
### ❌ Mistake #2: Hardcoding price IDs
```typescript
// WRONG - Hardcoded
const priceId = 'price_abc123'
// CORRECT - Use env vars
const priceId = process.env.NEXT_PUBLIC_STRIPE_SAAS_MONTHLY_PRICE_ID
```
### ❌ Mistake #3: Not including metadata
```typescript
// WRONG - Webhook won't know how to route
const session = await stripe.checkout.sessions.create({
line_items: [...],
mode: 'subscription',
})
// CORRECT - Include routing metadata
const session = await stripe.checkout.sessions.create({
line_items: [...],
mode: 'subscription',
metadata: {
clerk_user_id: userId,
package_type: 'concierge_plus',
payment_type: 'subscription',
},
})
```
### ❌ Mistake #4: Forgetting to await Stripe responses
```typescript
// WRONG - Missing await
const session = stripe.checkout.sessions.create({...})
// CORRECT
const session = await stripe.checkout.sessions.create({...})
```
## Changing Pricing
To change pricing (add tiers, adjust prices, etc.):
1. Update `src/lib/config/concierge-pricing.ts`
2. Create new Stripe products/prices
3. Update environment variables
4. No backend code changes needed!
The flexible architecture supports any pricing changes without touching server actions or webhooks.
## Documentation
- `STRIPE-SETUP-GUIDE.md` - Complete setup instructions
- `CAREBRIDGE-PRICING-IMPLEMENTATION.md` - Technical deep dive
- `scripts/README.md` - Script documentation