diff --git a/SESSION.md b/SESSION.md
new file mode 100644
index 0000000..433ee88
--- /dev/null
+++ b/SESSION.md
@@ -0,0 +1,163 @@
+# Session State
+
+**Project**: fullstack-next-cloudflare (Open Source Contributions)
+**Repository**: https://github.com/ifindev/fullstack-next-cloudflare
+**Fork**: https://github.com/jezweb/fullstack-next-cloudflare
+**Current Phase**: UX Improvements
+**Last Checkpoint**: fa233f3 (2025-11-08)
+
+---
+
+## Completed PRs ✅
+
+### Phase 1: Quick Fixes (PRs #11-16)
+**Status**: Complete | **Date**: 2025-11-08
+
+1. **PR #11** - Auto-detect port in auth client
+ - Fixed hardcoded localhost:3000 to use window.location.origin
+ - Prevents port conflicts in development
+
+2. **PR #12** - Fix navigation link
+ - Changed /todos → /dashboard/todos (404 fix)
+
+3. **PR #13** - Fix typos in method names
+ - buildSystenPrompt → buildSystemPrompt
+ - styleInstructructions → styleInstructions
+
+4. **PR #14** - Replace alert() with toast
+ - delete-todo.tsx: alert() → toast.error()
+
+5. **PR #15** - Add ARIA labels
+ - Added aria-label to delete button for accessibility
+
+6. **PR #16** - Add file validation
+ - File size limit: 5MB
+ - File types: PNG, JPG only
+ - Toast error messages
+
+---
+
+### Phase 2: Medium-Difficulty Fixes (PRs #17-20)
+**Status**: Complete | **Date**: 2025-11-08
+
+7. **PR #17** - Fix R2 URL double https://
+ - File: src/lib/r2.ts
+ - Removed hardcoded https:// prefix (env var already includes it)
+
+8. **PR #18** - Database ID environment variable
+ - Files: drizzle.config.ts, .dev.vars.example, README.md
+ - Added CLOUDFLARE_D1_DATABASE_ID env var
+ - Replaced hardcoded database ID
+
+9. **PR #19** - NEXT_REDIRECT error handling
+ - File: src/modules/todos/actions/update-todo.action.ts
+ - Added NEXT_REDIRECT handling to match createTodoAction
+
+10. **PR #20** - Standardize error responses
+ - Files: create-category.action.ts, add-category.tsx
+ - Changed from throw pattern to { success, data?, error? } pattern
+ - Consistent with other mutations
+
+---
+
+### Phase 3: Documentation (PR #21)
+**Status**: Complete | **Date**: 2025-11-08
+
+11. **PR #21** - Complete API documentation
+ - File: docs/API_ENDPOINTS.md (872 lines)
+ - REST endpoints: /api/summarize, /api/auth/*
+ - Server actions: 11 actions documented
+ - Data models, error handling, examples
+
+---
+
+## Current Phase: UX Improvements 🔄
+
+### Planned PRs (Next 5)
+
+**PR #22** - Replace alert() with toast (remaining instances)
+- Files: toggle-complete.tsx
+- Estimated: 15 min
+
+**PR #23** - Add success feedback for todo create/edit
+- Files: todo-form.tsx, create-todo.action.ts, update-todo.action.ts
+- Add toast.success() before redirect
+- Estimated: 30 min
+
+**PR #24** - Image upload failure warnings
+- Files: create-todo.action.ts, update-todo.action.ts
+- Show toast when R2 upload fails
+- Estimated: 20 min
+
+**PR #25** - Loading state for image uploads
+- File: todo-form.tsx
+- Add loading indicator/progress
+- Estimated: 45 min
+
+**PR #26** - Theme-aware colors (optional)
+- Files: todo-card.tsx, dashboard.page.tsx
+- Replace hard-coded colors with semantic theme colors
+- Estimated: 45 min
+
+---
+
+## Key Files Reference
+
+**Actions**:
+- `src/modules/todos/actions/create-todo.action.ts`
+- `src/modules/todos/actions/update-todo.action.ts`
+- `src/modules/todos/actions/delete-todo.action.ts`
+- `src/modules/todos/actions/create-category.action.ts`
+
+**Components**:
+- `src/modules/todos/components/todo-form.tsx`
+- `src/modules/todos/components/todo-card.tsx`
+- `src/modules/todos/components/delete-todo.tsx`
+- `src/modules/todos/components/toggle-complete.tsx`
+- `src/modules/todos/components/add-category.tsx`
+
+**API Routes**:
+- `src/app/api/summarize/route.ts`
+- `src/app/api/auth/[...all]/route.ts`
+
+**Config**:
+- `drizzle.config.ts`
+- `wrangler.jsonc`
+- `.dev.vars.example`
+
+---
+
+## Development Setup
+
+**Dev Servers Running**:
+- Wrangler: Port 8787 (Bash 23e213)
+- Next.js: Port 3001 (Bash d83043)
+- Additional: Bash bc259d
+
+**Environment**:
+- Account ID: 0460574641fdbb98159c98ebf593e2bd
+- Database ID: 757a32d1-5779-4f09-bcf3-b268013395d4
+- Auth: Google OAuth configured
+
+---
+
+## Contribution Stats
+
+**Total PRs**: 11 submitted
+**Lines Changed**: ~1,500+ lines
+**Documentation Added**: 872 lines
+**Issues Fixed**: 15+
+
+**Focus Areas**:
+- Error handling consistency
+- Environment configuration
+- API documentation
+- User experience improvements
+
+---
+
+## Next Action
+
+**After context compact**: Continue with UX improvement PRs (#22-26)
+
+Start with PR #22: Replace remaining alert() calls with toast notifications in toggle-complete.tsx
diff --git a/docs/DATABASE_SCHEMA.md b/docs/DATABASE_SCHEMA.md
new file mode 100644
index 0000000..74a0e4d
--- /dev/null
+++ b/docs/DATABASE_SCHEMA.md
@@ -0,0 +1,755 @@
+# Database Schema: Fullstack Next.js + Cloudflare CRM
+
+**Database**: Cloudflare D1 (distributed SQLite)
+**Migrations**: Located in `drizzle/`
+**ORM**: Drizzle ORM
+**Schema Files**: `src/modules/*/schemas/*.schema.ts`
+
+---
+
+## Overview
+
+The CRM extends the existing template database (users, sessions, todos, categories) with 4 new tables for contacts and deals management.
+
+**Existing Tables** (from template):
+- `user` - User accounts (Better Auth)
+- `session` - Active sessions (Better Auth)
+- `account` - OAuth provider accounts (Better Auth)
+- `verification` - Email verification tokens (Better Auth)
+- `todos` - Task management
+- `categories` - Todo categories
+
+**New CRM Tables** (added in Phase 2):
+- `contacts` - Contact profiles
+- `contact_tags` - User-defined tags
+- `contacts_to_tags` - Many-to-many junction table
+- `deals` - Sales pipeline deals
+
+---
+
+## Entity Relationship Diagram
+
+```mermaid
+erDiagram
+ user ||--o{ contacts : "owns"
+ user ||--o{ contact_tags : "creates"
+ user ||--o{ deals : "manages"
+
+ contacts ||--o{ contacts_to_tags : "tagged with"
+ contact_tags ||--o{ contacts_to_tags : "applied to"
+
+ contacts ||--o{ deals : "associated with"
+
+ user {
+ integer id PK
+ text email UK
+ text name
+ text image
+ integer emailVerified
+ integer createdAt
+ integer updatedAt
+ }
+
+ contacts {
+ integer id PK
+ text firstName
+ text lastName
+ text email
+ text phone
+ text company
+ text jobTitle
+ text notes
+ integer userId FK
+ integer createdAt
+ integer updatedAt
+ }
+
+ contact_tags {
+ integer id PK
+ text name
+ text color
+ integer userId FK
+ integer createdAt
+ }
+
+ contacts_to_tags {
+ integer contactId FK
+ integer tagId FK
+ }
+
+ deals {
+ integer id PK
+ text title
+ integer contactId FK
+ real value
+ text currency
+ text stage
+ integer expectedCloseDate
+ text description
+ integer userId FK
+ integer createdAt
+ integer updatedAt
+ }
+```
+
+---
+
+## Table Definitions
+
+### `contacts`
+
+**Purpose**: Store contact information for CRM
+
+| Column | Type | Constraints | Notes |
+|--------|------|-------------|-------|
+| id | INTEGER | PRIMARY KEY AUTOINCREMENT | Unique contact ID |
+| firstName | TEXT | | Optional - at least one name required |
+| lastName | TEXT | | Optional - at least one name required |
+| email | TEXT | | Optional but must be valid format |
+| phone | TEXT | | Optional, freeform (no format validation) |
+| company | TEXT | | Optional company name |
+| jobTitle | TEXT | | Optional job title/role |
+| notes | TEXT | | Optional freeform notes |
+| userId | INTEGER | NOT NULL, FOREIGN KEY → user(id) | Owner of contact |
+| createdAt | INTEGER | NOT NULL | Unix timestamp (milliseconds) |
+| updatedAt | INTEGER | NOT NULL | Unix timestamp (milliseconds) |
+
+**Indexes**:
+- `idx_contacts_user_id` on `userId` (filter by user)
+- `idx_contacts_email` on `email` (search by email)
+- `idx_contacts_company` on `company` (search by company)
+
+**Relationships**:
+- Many-to-one with `user` (each contact owned by one user)
+- Many-to-many with `contact_tags` (via junction table)
+- One-to-many with `deals` (contact can have multiple deals)
+
+**Business Rules**:
+- At least one of `firstName` or `lastName` must be non-empty (enforced in app, not DB)
+- Email must be unique per user (optional constraint, not enforced in MVP)
+- Deleting a user cascades to delete their contacts
+
+---
+
+### `contact_tags`
+
+**Purpose**: User-defined tags for organizing contacts
+
+| Column | Type | Constraints | Notes |
+|--------|------|-------------|-------|
+| id | INTEGER | PRIMARY KEY AUTOINCREMENT | Unique tag ID |
+| name | TEXT | NOT NULL | Tag name (e.g., "Customer", "Lead") |
+| color | TEXT | NOT NULL | Hex color code (e.g., "#3B82F6") |
+| userId | INTEGER | NOT NULL, FOREIGN KEY → user(id) | Tag owner |
+| createdAt | INTEGER | NOT NULL | Unix timestamp (milliseconds) |
+
+**Indexes**:
+- `idx_contact_tags_user_id` on `userId` (filter by user)
+- `idx_contact_tags_name_user` on `(name, userId)` (prevent duplicate tag names per user)
+
+**Relationships**:
+- Many-to-one with `user` (each tag owned by one user)
+- Many-to-many with `contacts` (via junction table)
+
+**Business Rules**:
+- Tag name must be unique per user (enforced in app)
+- Color must be valid hex format (enforced in app with Zod: `/^#[0-9A-Fa-f]{6}$/`)
+- Deleting a tag removes all tag assignments (via cascade on junction table)
+
+---
+
+### `contacts_to_tags` (Junction Table)
+
+**Purpose**: Many-to-many relationship between contacts and tags
+
+| Column | Type | Constraints | Notes |
+|--------|------|-------------|-------|
+| contactId | INTEGER | FOREIGN KEY → contacts(id) ON DELETE CASCADE | Contact being tagged |
+| tagId | INTEGER | FOREIGN KEY → contact_tags(id) ON DELETE CASCADE | Tag being applied |
+
+**Composite Primary Key**: `(contactId, tagId)`
+
+**Indexes**:
+- Primary key index on `(contactId, tagId)` (prevent duplicate assignments)
+- `idx_contacts_to_tags_tag_id` on `tagId` (query contacts by tag)
+
+**Relationships**:
+- Many-to-one with `contacts`
+- Many-to-one with `contact_tags`
+
+**Business Rules**:
+- A contact can have multiple tags
+- A tag can be applied to multiple contacts
+- Same tag cannot be assigned to a contact twice (enforced by composite PK)
+- Deleting a contact removes all its tag assignments (ON DELETE CASCADE)
+- Deleting a tag removes all its assignments (ON DELETE CASCADE)
+
+---
+
+### `deals`
+
+**Purpose**: Sales pipeline deals linked to contacts
+
+| Column | Type | Constraints | Notes |
+|--------|------|-------------|-------|
+| id | INTEGER | PRIMARY KEY AUTOINCREMENT | Unique deal ID |
+| title | TEXT | NOT NULL | Deal name/description |
+| contactId | INTEGER | FOREIGN KEY → contacts(id) ON DELETE SET NULL | Linked contact (optional) |
+| value | REAL | NOT NULL | Deal value (e.g., 5000.00) |
+| currency | TEXT | NOT NULL DEFAULT 'AUD' | Currency code (ISO 4217) |
+| stage | TEXT | NOT NULL | Pipeline stage (see enum below) |
+| expectedCloseDate | INTEGER | | Expected close date (unix timestamp) |
+| description | TEXT | | Optional deal notes |
+| userId | INTEGER | NOT NULL, FOREIGN KEY → user(id) | Deal owner |
+| createdAt | INTEGER | NOT NULL | Unix timestamp (milliseconds) |
+| updatedAt | INTEGER | NOT NULL | Unix timestamp (milliseconds) |
+
+**Stage Enum** (enforced in app):
+- `"Prospecting"` - Initial contact
+- `"Qualification"` - Evaluating fit
+- `"Proposal"` - Proposal sent
+- `"Negotiation"` - Negotiating terms
+- `"Closed Won"` - Deal won
+- `"Closed Lost"` - Deal lost
+
+**Indexes**:
+- `idx_deals_user_id` on `userId` (filter by user)
+- `idx_deals_contact_id` on `contactId` (filter by contact)
+- `idx_deals_stage` on `stage` (filter by pipeline stage)
+- `idx_deals_user_stage` on `(userId, stage)` (pipeline board queries)
+
+**Relationships**:
+- Many-to-one with `user` (each deal owned by one user)
+- Many-to-one with `contacts` (optional - deal can exist without contact)
+
+**Business Rules**:
+- Contact link is optional (`contactId` can be NULL)
+- Deleting a contact does NOT delete deals (sets `contactId` to NULL)
+- Stage must be one of the 6 valid enum values (enforced in app with Zod)
+- Value must be positive (enforced in app)
+- Currency defaults to AUD (user can change in form)
+- Deleting a user cascades to delete their deals
+
+---
+
+## Data Types & Conventions
+
+### Timestamps
+
+**Storage**: INTEGER (unix timestamp in milliseconds)
+
+**Creation**:
+```typescript
+const now = Date.now() // JavaScript
+```
+
+**Display**:
+```typescript
+new Date(timestamp).toLocaleDateString('en-AU')
+new Date(timestamp).toLocaleString('en-AU')
+```
+
+**Drizzle Schema**:
+```typescript
+createdAt: integer('created_at', { mode: 'number' })
+ .notNull()
+ .$defaultFn(() => Date.now())
+```
+
+### Foreign Keys
+
+**Syntax in Drizzle**:
+```typescript
+userId: integer('user_id')
+ .notNull()
+ .references(() => userTable.id, { onDelete: 'cascade' })
+```
+
+**Cascade Behavior**:
+- `onDelete: 'cascade'` - Delete child records when parent deleted
+- `onDelete: 'set null'` - Set foreign key to NULL when parent deleted
+
+**Applied**:
+- User → Contacts: CASCADE (delete user's contacts)
+- User → Tags: CASCADE (delete user's tags)
+- User → Deals: CASCADE (delete user's deals)
+- Contact → Deals: SET NULL (keep deal, remove contact link)
+- Contact → Junction: CASCADE (remove tag assignments)
+- Tag → Junction: CASCADE (remove contact assignments)
+
+### Enums
+
+**Deal Stage** (not enforced in DB, only in app):
+```typescript
+export const dealStageEnum = [
+ 'Prospecting',
+ 'Qualification',
+ 'Proposal',
+ 'Negotiation',
+ 'Closed Won',
+ 'Closed Lost',
+] as const
+
+export type DealStage = typeof dealStageEnum[number]
+```
+
+**Validation in Zod**:
+```typescript
+stage: z.enum(dealStageEnum)
+```
+
+### Text Fields
+
+**SQLite TEXT type** (no length limits):
+- Short fields: email, phone, name, color
+- Long fields: notes, description
+
+**Encoding**: UTF-8
+
+**Collation**: Use `COLLATE NOCASE` for case-insensitive searches:
+```sql
+WHERE email LIKE '%query%' COLLATE NOCASE
+```
+
+### Numeric Fields
+
+**INTEGER**: Whole numbers (IDs, timestamps, foreign keys)
+**REAL**: Floating point (deal values)
+
+---
+
+## Migrations
+
+### Migration 0001: Initial Schema (Template)
+
+**File**: `drizzle/0000_*.sql`
+
+**Creates**:
+- `user` table (Better Auth)
+- `session` table (Better Auth)
+- `account` table (Better Auth)
+- `verification` table (Better Auth)
+- `todos` table (template feature)
+- `categories` table (template feature)
+
+**Status**: Already applied (template setup)
+
+---
+
+### Migration 0002: CRM Schema (Phase 2)
+
+**File**: `drizzle/0002_*.sql`
+
+**Creates**:
+- `contacts` table with indexes
+- `contact_tags` table with indexes
+- `contacts_to_tags` junction table with composite PK
+- `deals` table with indexes
+
+**SQL Preview**:
+```sql
+CREATE TABLE contacts (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ first_name TEXT,
+ last_name TEXT,
+ email TEXT,
+ phone TEXT,
+ company TEXT,
+ job_title TEXT,
+ notes TEXT,
+ user_id INTEGER NOT NULL REFERENCES user(id) ON DELETE CASCADE,
+ created_at INTEGER NOT NULL,
+ updated_at INTEGER NOT NULL
+);
+
+CREATE INDEX idx_contacts_user_id ON contacts(user_id);
+CREATE INDEX idx_contacts_email ON contacts(email);
+CREATE INDEX idx_contacts_company ON contacts(company);
+
+CREATE TABLE contact_tags (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name TEXT NOT NULL,
+ color TEXT NOT NULL,
+ user_id INTEGER NOT NULL REFERENCES user(id) ON DELETE CASCADE,
+ created_at INTEGER NOT NULL
+);
+
+CREATE INDEX idx_contact_tags_user_id ON contact_tags(user_id);
+CREATE UNIQUE INDEX idx_contact_tags_name_user ON contact_tags(name, user_id);
+
+CREATE TABLE contacts_to_tags (
+ contact_id INTEGER NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
+ tag_id INTEGER NOT NULL REFERENCES contact_tags(id) ON DELETE CASCADE,
+ PRIMARY KEY (contact_id, tag_id)
+);
+
+CREATE INDEX idx_contacts_to_tags_tag_id ON contacts_to_tags(tag_id);
+
+CREATE TABLE deals (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ title TEXT NOT NULL,
+ contact_id INTEGER REFERENCES contacts(id) ON DELETE SET NULL,
+ value REAL NOT NULL,
+ currency TEXT NOT NULL DEFAULT 'AUD',
+ stage TEXT NOT NULL,
+ expected_close_date INTEGER,
+ description TEXT,
+ user_id INTEGER NOT NULL REFERENCES user(id) ON DELETE CASCADE,
+ created_at INTEGER NOT NULL,
+ updated_at INTEGER NOT NULL
+);
+
+CREATE INDEX idx_deals_user_id ON deals(user_id);
+CREATE INDEX idx_deals_contact_id ON deals(contact_id);
+CREATE INDEX idx_deals_stage ON deals(stage);
+CREATE INDEX idx_deals_user_stage ON deals(user_id, stage);
+```
+
+**Apply**:
+```bash
+# Local
+pnpm run db:migrate:local
+
+# Production
+pnpm run db:migrate
+```
+
+---
+
+## Seed Data (Phase 6)
+
+### Contacts
+
+10 sample contacts with variety:
+- 3 contacts with multiple tags
+- 2 contacts linked to deals
+- Mix of complete and minimal profiles
+
+Example:
+```typescript
+{
+ firstName: 'Sarah',
+ lastName: 'Chen',
+ email: 'sarah.chen@example.com',
+ phone: '+61 412 345 678',
+ company: 'TechCorp Australia',
+ jobTitle: 'CTO',
+ notes: 'Met at DevConf 2024. Interested in AI features.',
+ userId: 1,
+ createdAt: Date.now(),
+ updatedAt: Date.now()
+}
+```
+
+### Tags
+
+5 common tags:
+- Customer (#10B981 - green)
+- Lead (#3B82F6 - blue)
+- Partner (#8B5CF6 - purple)
+- Inactive (#6B7280 - gray)
+- VIP (#F59E0B - amber)
+
+### Deals
+
+5 deals across all stages:
+```typescript
+[
+ { title: 'Enterprise License', stage: 'Prospecting', value: 5000 },
+ { title: 'Consulting Package', stage: 'Qualification', value: 12000 },
+ { title: 'Annual Support', stage: 'Proposal', value: 25000 },
+ { title: 'Cloud Migration', stage: 'Closed Won', value: 50000 },
+ { title: 'Training Program', stage: 'Closed Lost', value: 8000 },
+]
+```
+
+---
+
+## Query Patterns
+
+### Fetch Contacts with Tags
+
+```typescript
+// Using Drizzle ORM
+const contactsWithTags = await db
+ .select({
+ id: contacts.id,
+ firstName: contacts.firstName,
+ lastName: contacts.lastName,
+ email: contacts.email,
+ company: contacts.company,
+ tags: sql`json_group_array(json_object('id', ${contactTags.id}, 'name', ${contactTags.name}, 'color', ${contactTags.color}))`,
+ })
+ .from(contacts)
+ .leftJoin(contactsToTags, eq(contacts.id, contactsToTags.contactId))
+ .leftJoin(contactTags, eq(contactsToTags.tagId, contactTags.id))
+ .where(eq(contacts.userId, userId))
+ .groupBy(contacts.id)
+```
+
+### Search Contacts
+
+```typescript
+// Case-insensitive search across name, email, company
+const results = await db
+ .select()
+ .from(contacts)
+ .where(
+ and(
+ eq(contacts.userId, userId),
+ or(
+ sql`${contacts.firstName} LIKE ${`%${query}%`} COLLATE NOCASE`,
+ sql`${contacts.lastName} LIKE ${`%${query}%`} COLLATE NOCASE`,
+ sql`${contacts.email} LIKE ${`%${query}%`} COLLATE NOCASE`,
+ sql`${contacts.company} LIKE ${`%${query}%`} COLLATE NOCASE`
+ )
+ )
+ )
+```
+
+### Fetch Deals with Contact Info
+
+```typescript
+// Join deals with contacts
+const dealsWithContacts = await db
+ .select({
+ id: deals.id,
+ title: deals.title,
+ value: deals.value,
+ currency: deals.currency,
+ stage: deals.stage,
+ contactName: sql`${contacts.firstName} || ' ' || ${contacts.lastName}`,
+ contactEmail: contacts.email,
+ })
+ .from(deals)
+ .leftJoin(contacts, eq(deals.contactId, contacts.id))
+ .where(eq(deals.userId, userId))
+ .orderBy(deals.createdAt)
+```
+
+### Dashboard Metrics
+
+```typescript
+// Count contacts
+const totalContacts = await db
+ .select({ count: sql`count(*)` })
+ .from(contacts)
+ .where(eq(contacts.userId, userId))
+
+// Count active deals
+const activeDeals = await db
+ .select({ count: sql`count(*)` })
+ .from(deals)
+ .where(
+ and(
+ eq(deals.userId, userId),
+ notInArray(deals.stage, ['Closed Won', 'Closed Lost'])
+ )
+ )
+
+// Sum pipeline value
+const pipelineValue = await db
+ .select({ total: sql`sum(${deals.value})` })
+ .from(deals)
+ .where(
+ and(
+ eq(deals.userId, userId),
+ notInArray(deals.stage, ['Closed Won', 'Closed Lost'])
+ )
+ )
+```
+
+---
+
+## Performance Considerations
+
+### Indexes
+
+**Query Patterns**:
+- Filter by userId (every query) → Index on `user_id` for all tables
+- Search by email/company → Indexes on `email`, `company` in contacts
+- Filter deals by stage → Index on `stage`
+- Pipeline board query → Composite index on `(user_id, stage)`
+
+**Trade-offs**:
+- Indexes speed up SELECT queries
+- Indexes slow down INSERT/UPDATE/DELETE (minimal impact for CRM scale)
+- SQLite handles small indexes efficiently
+
+### N+1 Query Prevention
+
+**Anti-pattern**:
+```typescript
+// BAD: N+1 queries
+const contacts = await getContacts(userId)
+for (const contact of contacts) {
+ const tags = await getTagsForContact(contact.id) // N queries!
+}
+```
+
+**Solution**:
+```typescript
+// GOOD: Single query with JOIN
+const contactsWithTags = await db
+ .select()
+ .from(contacts)
+ .leftJoin(contactsToTags, ...)
+ .leftJoin(contactTags, ...)
+ .groupBy(contacts.id)
+```
+
+### Pagination
+
+**For lists >50 items**:
+```typescript
+const pageSize = 50
+const offset = (page - 1) * pageSize
+
+const contacts = await db
+ .select()
+ .from(contacts)
+ .where(eq(contacts.userId, userId))
+ .limit(pageSize)
+ .offset(offset)
+```
+
+**Not needed for MVP** (small data sets), but good practice for Phase 2.
+
+---
+
+## Security
+
+### User Isolation
+
+**Critical**: Every query MUST filter by `userId` to prevent data leakage.
+
+**Pattern**:
+```typescript
+// Always include userId in WHERE clause
+await db
+ .select()
+ .from(contacts)
+ .where(eq(contacts.userId, currentUserId))
+```
+
+**Enforcement**:
+- Server Actions use `requireAuth()` to get `currentUserId`
+- Never trust client-provided `userId` parameter
+- Always re-fetch user from session token
+
+### Ownership Verification
+
+**Before UPDATE/DELETE**:
+```typescript
+// 1. Fetch record
+const contact = await db.query.contacts.findFirst({
+ where: eq(contacts.id, contactId)
+})
+
+// 2. Verify ownership
+if (contact.userId !== currentUserId) {
+ throw new Error('Forbidden')
+}
+
+// 3. Mutate
+await db.update(contacts).set(...).where(eq(contacts.id, contactId))
+```
+
+### SQL Injection Prevention
+
+**Drizzle ORM handles parameterization automatically**:
+```typescript
+// Safe - Drizzle uses prepared statements
+const results = await db
+ .select()
+ .from(contacts)
+ .where(sql`email LIKE ${`%${userInput}%`}`)
+// userInput is safely escaped
+```
+
+**Never use string concatenation**:
+```typescript
+// UNSAFE - DO NOT DO THIS
+const query = `SELECT * FROM contacts WHERE email = '${userInput}'`
+```
+
+---
+
+## Backup & Recovery
+
+### Local D1 Backup
+
+**Location**: `.wrangler/state/v3/d1/miniflare-D1DatabaseObject/*.sqlite`
+
+**Backup command**:
+```bash
+cp .wrangler/state/v3/d1/miniflare-D1DatabaseObject/*.sqlite backup-$(date +%Y%m%d).sqlite
+```
+
+### Production D1 Backup
+
+**Export**:
+```bash
+npx wrangler d1 export fullstack-crm --remote --output backup.sql
+```
+
+**Restore**:
+```bash
+npx wrangler d1 execute fullstack-crm --remote --file backup.sql
+```
+
+**Frequency**: Manual for MVP, automate with GitHub Actions for production.
+
+---
+
+## Future Enhancements (Phase 2)
+
+### Activity Timeline
+
+Add `contact_activities` table:
+```sql
+CREATE TABLE contact_activities (
+ id INTEGER PRIMARY KEY,
+ contact_id INTEGER REFERENCES contacts(id) ON DELETE CASCADE,
+ type TEXT NOT NULL, -- 'call', 'email', 'meeting', 'note'
+ subject TEXT,
+ description TEXT,
+ timestamp INTEGER NOT NULL,
+ user_id INTEGER REFERENCES user(id) ON DELETE CASCADE
+);
+```
+
+### Custom Deal Stages
+
+Add `deal_stages` table (user-defined):
+```sql
+CREATE TABLE deal_stages (
+ id INTEGER PRIMARY KEY,
+ name TEXT NOT NULL,
+ order_index INTEGER NOT NULL,
+ color TEXT,
+ user_id INTEGER REFERENCES user(id) ON DELETE CASCADE,
+ UNIQUE(name, user_id)
+);
+```
+
+Modify `deals.stage` to reference `deal_stages.id` instead of enum.
+
+### Soft Delete
+
+Add `deleted_at` column to contacts and deals:
+```sql
+ALTER TABLE contacts ADD COLUMN deleted_at INTEGER;
+ALTER TABLE deals ADD COLUMN deleted_at INTEGER;
+```
+
+Filter deleted records: `WHERE deleted_at IS NULL`
+
+---
+
+## References
+
+- **Drizzle ORM Docs**: https://orm.drizzle.team/docs/overview
+- **SQLite Data Types**: https://www.sqlite.org/datatype3.html
+- **D1 Documentation**: https://developers.cloudflare.com/d1/
+- **Better Auth Schema**: https://www.better-auth.com/docs/concepts/database
diff --git a/docs/IMPLEMENTATION_PHASES.md b/docs/IMPLEMENTATION_PHASES.md
new file mode 100644
index 0000000..d38876a
--- /dev/null
+++ b/docs/IMPLEMENTATION_PHASES.md
@@ -0,0 +1,836 @@
+# Implementation Phases: Fullstack Next.js + Cloudflare CRM
+
+**Project Type**: Learning Exercise - CRM Features
+**Stack**: Next.js 15 + Cloudflare Workers + D1 + Drizzle ORM + Tailwind v4 + shadcn/ui
+**Estimated Total**: 6-8 hours (~6-8 minutes human time with Claude Code)
+
+---
+
+## Phase 1: Project Setup
+
+**Type**: Infrastructure
+**Estimated**: 30 minutes
+**Files**: `wrangler.jsonc`, `.dev.vars`, `package.json`
+
+### Purpose
+
+Clone the project to a new directory, configure Cloudflare D1 database for your account, set up environment variables, and verify the development environment works.
+
+### File Map
+
+- `wrangler.jsonc` (modify existing)
+ - **Purpose**: Cloudflare Workers configuration
+ - **Modifications**: Update D1 database ID to your account's database
+ - **Used by**: Wrangler CLI for deployment and local dev
+
+- `.dev.vars` (create new)
+ - **Purpose**: Local development secrets
+ - **Contains**: Better Auth secrets, Google OAuth credentials
+ - **Used by**: Local development server
+
+### Tasks
+
+- [ ] Clone project from `/home/jez/Documents/fullstack-next-cloudflare-demo` to `/home/jez/Documents/fullstack-next-cloudflare-crm`
+- [ ] Install dependencies with `pnpm install`
+- [ ] Create new D1 database: `npx wrangler d1 create fullstack-crm`
+- [ ] Update `wrangler.jsonc` with new database ID (replace `757a32d1-5779-4f09-bcf3-b268013395d4`)
+- [ ] Create `.dev.vars` file with Better Auth secrets (copy from demo project if available)
+- [ ] Run existing migrations: `pnpm run db:migrate:local`
+- [ ] Start dev servers (two terminals): `pnpm run wrangler:dev` and `pnpm run dev`
+- [ ] Verify app loads at http://localhost:5173
+- [ ] Create git repository and initial commit
+
+### Verification Criteria
+
+- [ ] App loads without errors in browser
+- [ ] Can navigate to /dashboard (after login)
+- [ ] Existing todos feature works (proves D1 connection)
+- [ ] No console errors
+- [ ] Git repository initialized
+
+### Exit Criteria
+
+Development environment is fully functional with new D1 database configured. Can run both Wrangler and Next.js dev servers simultaneously. Existing template features work correctly.
+
+---
+
+## Phase 2: Database Schema
+
+**Type**: Database
+**Estimated**: 1 hour
+**Files**: `src/modules/contacts/schemas/contact.schema.ts`, `src/modules/contacts/schemas/tag.schema.ts`, `src/modules/deals/schemas/deal.schema.ts`, `drizzle/0002_crm_schema.sql`
+
+### Purpose
+
+Create database tables for contacts, tags, and deals with proper relationships. Follows the existing pattern from `todos` and `categories` tables.
+
+### File Map
+
+- `src/modules/contacts/schemas/contact.schema.ts` (new ~60 lines)
+ - **Purpose**: Drizzle schema for contacts table and tags
+ - **Key exports**: contactsTable, contactTagsTable, contactsToTagsTable
+ - **Dependencies**: drizzle-orm/d1, src/modules/auth/schemas (user relation)
+ - **Used by**: Contact actions, migration generation
+
+- `src/modules/deals/schemas/deal.schema.ts` (new ~50 lines)
+ - **Purpose**: Drizzle schema for deals table
+ - **Key exports**: dealsTable, dealStageEnum
+ - **Dependencies**: drizzle-orm/d1, contacts schema (foreign key)
+ - **Used by**: Deal actions, migration generation
+
+- `src/db/schema.ts` (modify existing)
+ - **Purpose**: Aggregate all schemas for migrations
+ - **Modifications**: Export new CRM schemas
+ - **Used by**: Drizzle Kit for migration generation
+
+- `drizzle/0002_crm_schema.sql` (generated)
+ - **Purpose**: Migration file to create CRM tables
+ - **Creates**: contacts, contact_tags, contacts_to_tags, deals tables
+ - **Generated by**: `pnpm drizzle-kit generate`
+
+### Data Flow
+
+```mermaid
+erDiagram
+ users ||--o{ contacts : "owns"
+ users ||--o{ contact_tags : "owns"
+ users ||--o{ deals : "owns"
+ contacts ||--o{ contacts_to_tags : "has"
+ contact_tags ||--o{ contacts_to_tags : "tagged"
+ contacts ||--o{ deals : "linked to"
+
+ users {
+ integer id PK
+ text email
+ text name
+ }
+
+ contacts {
+ integer id PK
+ text firstName
+ text lastName
+ text email
+ text phone
+ text company
+ text jobTitle
+ text notes
+ integer userId FK
+ integer createdAt
+ integer updatedAt
+ }
+
+ contact_tags {
+ integer id PK
+ text name
+ text color
+ integer userId FK
+ integer createdAt
+ }
+
+ contacts_to_tags {
+ integer contactId FK
+ integer tagId FK
+ }
+
+ deals {
+ integer id PK
+ text title
+ integer contactId FK
+ real value
+ text currency
+ text stage
+ integer expectedCloseDate
+ text description
+ integer userId FK
+ integer createdAt
+ integer updatedAt
+ }
+```
+
+### Critical Dependencies
+
+**Internal**:
+- Auth schemas (`src/modules/auth/schemas`) for user foreign keys
+- Existing D1 database from Phase 1
+
+**External**:
+- `drizzle-orm` - ORM for type-safe queries
+- `drizzle-kit` - Migration generation
+
+**Configuration**:
+- `drizzle.local.config.ts` - Points to local D1 database
+
+### Gotchas & Known Issues
+
+**Many-to-Many Pattern**:
+- Junction table `contacts_to_tags` requires composite primary key `(contactId, tagId)`
+- Drizzle syntax: `primaryKey: ["contactId", "tagId"]`
+- Querying requires joins - study Drizzle docs for many-to-many queries
+
+**Foreign Key Constraints**:
+- SQLite (D1) supports foreign keys but they must be enabled
+- Use `.onDelete("cascade")` for tags (deleting tag removes associations)
+- Use `.onDelete("set null")` for deals.contactId (deleting contact keeps deal)
+
+**Timestamp Pattern**:
+- Store as INTEGER (unix timestamp in milliseconds)
+- Use `.$defaultFn(() => Date.now())` for createdAt
+- Use `.notNull()` for required fields
+
+**Deal Stages as Enum**:
+- Fixed stages for MVP: Prospecting, Qualification, Proposal, Negotiation, Closed Won, Closed Lost
+- Stored as TEXT with enum constraint
+- Phase 2 feature: Custom user-defined stages (requires separate table)
+
+### Tasks
+
+- [ ] Create `src/modules/contacts/schemas/` directory
+- [ ] Create `contact.schema.ts` with contactsTable definition (firstName, lastName, email, phone, company, jobTitle, notes, userId, timestamps)
+- [ ] Add contactTagsTable definition (id, name, color, userId, createdAt)
+- [ ] Add contactsToTagsTable junction table (contactId, tagId, composite PK)
+- [ ] Create `src/modules/deals/schemas/` directory
+- [ ] Create `deal.schema.ts` with dealsTable definition (title, contactId FK, value, currency, stage enum, expectedCloseDate, description, userId, timestamps)
+- [ ] Update `src/db/schema.ts` to export new schemas
+- [ ] Generate migration: `pnpm drizzle-kit generate`
+- [ ] Review generated SQL in `drizzle/0002_crm_schema.sql`
+- [ ] Run migration locally: `pnpm run db:migrate:local`
+- [ ] Verify tables created in D1: `npx wrangler d1 execute fullstack-crm --local --command "SELECT name FROM sqlite_master WHERE type='table'"`
+
+### Verification Criteria
+
+- [ ] Migration generates without errors
+- [ ] Migration runs successfully (local D1)
+- [ ] Can see 7 new tables in D1: contacts, contact_tags, contacts_to_tags, deals (plus existing user/session/account/verification/todos/categories)
+- [ ] Foreign key constraints are correct (userId → users, contactId → contacts, tagId → contact_tags)
+- [ ] Composite primary key exists on contacts_to_tags (contactId, tagId)
+- [ ] Deal stage enum constraint is enforced
+
+### Exit Criteria
+
+All CRM database tables exist with proper relationships and constraints. Can manually insert test data via Wrangler CLI to verify schema. Migration is version-controlled and ready for production deployment.
+
+---
+
+## Phase 3: Contacts Module
+
+**Type**: UI + Server Actions
+**Estimated**: 2.5 hours
+**Files**: See File Map (8 files total)
+
+### Purpose
+
+Implement complete contacts CRUD functionality with search, filtering, and tag management. Follows the existing `todos` module pattern.
+
+### File Map
+
+- `src/modules/contacts/actions/create-contact.action.ts` (new ~40 lines)
+ - **Purpose**: Server action to create new contact
+ - **Key exports**: createContact(data)
+ - **Dependencies**: contact.schema.ts, getDb(), requireAuth()
+ - **Returns**: Created contact or error
+
+- `src/modules/contacts/actions/get-contacts.action.ts` (new ~60 lines)
+ - **Purpose**: Server action to fetch contacts with search/filter
+ - **Key exports**: getContacts(searchQuery?, tagId?)
+ - **Dependencies**: contact.schema.ts, getDb(), requireAuth()
+ - **Returns**: Array of contacts with tags
+
+- `src/modules/contacts/actions/update-contact.action.ts` (new ~50 lines)
+ - **Purpose**: Server action to update contact
+ - **Key exports**: updateContact(id, data)
+ - **Dependencies**: contact.schema.ts, getDb(), requireAuth()
+ - **Returns**: Updated contact or error
+
+- `src/modules/contacts/actions/delete-contact.action.ts` (new ~35 lines)
+ - **Purpose**: Server action to delete contact
+ - **Key exports**: deleteContact(id)
+ - **Dependencies**: contact.schema.ts, getDb(), requireAuth()
+ - **Returns**: Success boolean
+
+- `src/modules/contacts/actions/tag-management.actions.ts` (new ~80 lines)
+ - **Purpose**: Server actions for tag CRUD and assignment
+ - **Key exports**: createTag(), getTags(), assignTagToContact(), removeTagFromContact()
+ - **Dependencies**: tag.schema.ts, getDb(), requireAuth()
+ - **Returns**: Tag operations results
+
+- `src/modules/contacts/components/contact-form.tsx` (new ~120 lines)
+ - **Purpose**: Reusable form for create/edit contact
+ - **Key exports**: ContactForm component
+ - **Dependencies**: React Hook Form, Zod, shadcn/ui (Input, Textarea, Button, Form), actions
+ - **Used by**: New contact page, Edit contact page
+
+- `src/modules/contacts/components/contact-card.tsx` (new ~80 lines)
+ - **Purpose**: Display single contact with actions
+ - **Key exports**: ContactCard component
+ - **Dependencies**: shadcn/ui (Card, Badge), actions (delete, tags)
+ - **Used by**: Contact list page
+
+- `src/app/dashboard/contacts/page.tsx` (new ~90 lines)
+ - **Purpose**: Contact list page with search and filter
+ - **Route**: /dashboard/contacts
+ - **Dependencies**: getContacts action, ContactCard, shadcn/ui (Input for search)
+ - **Features**: Search by name/email/company, filter by tag
+
+### Data Flow
+
+```mermaid
+sequenceDiagram
+ participant U as User
+ participant C as ContactForm Component
+ participant A as createContact Action
+ participant D as D1 Database
+ participant R as React (revalidate)
+
+ U->>C: Fill form + submit
+ C->>C: Validate with Zod
+ C->>A: createContact(validatedData)
+ A->>A: requireAuth() - get userId
+ A->>D: INSERT INTO contacts
+ D->>A: New contact record
+ A->>R: revalidatePath('/dashboard/contacts')
+ R->>C: Trigger re-render
+ A->>C: Return success
+ C->>U: Show success toast + redirect
+```
+
+### Critical Dependencies
+
+**Internal**:
+- Contact schemas from Phase 2
+- Auth utilities (`src/modules/auth/utils/server.ts` - requireAuth, getCurrentUser)
+- DB connection (`src/db/index.ts` - getDb)
+
+**External**:
+- `react-hook-form` - Form state management
+- `zod` - Validation schemas
+- `@hookform/resolvers/zod` - RHF + Zod integration
+- shadcn/ui components: Card, Input, Textarea, Button, Form, Badge, Select
+
+**Configuration**:
+- None - uses existing D1 binding from Phase 1
+
+### Gotchas & Known Issues
+
+**Ownership Verification Critical**:
+- UPDATE/DELETE must check `contact.userId === user.id`
+- Without check, users can modify others' contacts (security vulnerability)
+- Pattern: Fetch contact first, verify ownership, then mutate
+
+**Search Implementation**:
+- D1 (SQLite) doesn't have full-text search
+- Use `LIKE` queries for MVP: `WHERE firstName LIKE '%query%' OR lastName LIKE '%query%' OR email LIKE '%query%'`
+- Case-insensitive: Use `COLLATE NOCASE` in SQL
+- Performance: Acceptable for <1000 contacts per user
+
+**Many-to-Many Tag Queries**:
+- Fetching contacts with tags requires LEFT JOIN on junction table
+- Drizzle syntax is verbose - see examples in Drizzle docs
+- Consider using `.with()` or raw SQL for complex queries
+
+**Tag Color Validation**:
+- Store as hex string (#FF5733)
+- Validate format with Zod regex: `z.string().regex(/^#[0-9A-Fa-f]{6}$/)`
+- Provide color picker in UI (use shadcn/ui Popover + color grid)
+
+**Form Validation**:
+- Email is optional but must be valid format if provided: `z.string().email().optional().or(z.literal(''))`
+- Phone is optional and freeform (no format enforcement for MVP)
+- At least one of firstName or lastName required
+
+### Tasks
+
+- [ ] Create actions directory and implement all 5 action files
+- [ ] Create validation schemas for contact create/update (Zod)
+- [ ] Implement getContacts with search and filter logic (LIKE queries + LEFT JOIN for tags)
+- [ ] Implement tag CRUD actions (create, list, assign to contact, remove from contact)
+- [ ] Create ContactForm component with React Hook Form + Zod validation
+- [ ] Create ContactCard component with delete button and tag badges
+- [ ] Create /dashboard/contacts page with search input and tag filter dropdown
+- [ ] Create /dashboard/contacts/new page with ContactForm
+- [ ] Create /dashboard/contacts/[id]/edit page with ContactForm (pre-filled)
+- [ ] Add navigation link in dashboard layout
+- [ ] Test all CRUD operations manually
+
+### Verification Criteria
+
+- [ ] Can create new contact with valid data (redirects to contacts list)
+- [ ] Form validation catches invalid email format
+- [ ] Can search contacts by firstName, lastName, email, company (case-insensitive)
+- [ ] Can create new tags with name and color
+- [ ] Can assign multiple tags to a contact
+- [ ] Can remove tag from contact
+- [ ] Can edit contact (form pre-fills with existing data)
+- [ ] Can delete contact (shows confirmation, removes from list)
+- [ ] Cannot edit/delete another user's contacts (403 error)
+- [ ] Contact list updates immediately after create/update/delete (revalidation works)
+- [ ] UI is responsive on mobile and desktop
+
+### Exit Criteria
+
+Complete contacts management system with CRUD, search, and tagging. Users can create, view, edit, delete, and organize contacts. All operations respect user ownership. Forms validate inputs properly. UI follows shadcn/ui design patterns.
+
+---
+
+## Phase 4: Deals Module
+
+**Type**: UI + Server Actions
+**Estimated**: 2 hours
+**Files**: See File Map (7 files total)
+
+### Purpose
+
+Implement deals/pipeline management with CRUD operations, contact linking, and simple Kanban-style board view.
+
+### File Map
+
+- `src/modules/deals/actions/create-deal.action.ts` (new ~45 lines)
+ - **Purpose**: Server action to create deal
+ - **Key exports**: createDeal(data)
+ - **Dependencies**: deal.schema.ts, getDb(), requireAuth()
+ - **Returns**: Created deal with contact info
+
+- `src/modules/deals/actions/get-deals.action.ts` (new ~70 lines)
+ - **Purpose**: Server action to fetch deals with filters
+ - **Key exports**: getDeals(stage?, contactId?)
+ - **Dependencies**: deal.schema.ts, contact.schema.ts, getDb(), requireAuth()
+ - **Returns**: Array of deals with JOIN to contacts table
+
+- `src/modules/deals/actions/update-deal.action.ts` (new ~55 lines)
+ - **Purpose**: Server action to update deal (including stage changes)
+ - **Key exports**: updateDeal(id, data)
+ - **Dependencies**: deal.schema.ts, getDb(), requireAuth()
+ - **Returns**: Updated deal
+
+- `src/modules/deals/actions/delete-deal.action.ts` (new ~35 lines)
+ - **Purpose**: Server action to delete deal
+ - **Key exports**: deleteDeal(id)
+ - **Dependencies**: deal.schema.ts, getDb(), requireAuth()
+ - **Returns**: Success boolean
+
+- `src/modules/deals/components/deal-form.tsx` (new ~110 lines)
+ - **Purpose**: Form for create/edit deal
+ - **Key exports**: DealForm component
+ - **Dependencies**: React Hook Form, Zod, shadcn/ui (Input, Select, Textarea), getContacts action
+ - **Features**: Contact dropdown selector, currency input, date picker for expectedCloseDate
+
+- `src/modules/deals/components/deal-card.tsx` (new ~70 lines)
+ - **Purpose**: Display deal in board column
+ - **Key exports**: DealCard component
+ - **Dependencies**: shadcn/ui (Card, Badge), currency formatter
+ - **Used by**: Pipeline board
+
+- `src/app/dashboard/deals/page.tsx` (new ~100 lines)
+ - **Purpose**: Pipeline Kanban board view
+ - **Route**: /dashboard/deals
+ - **Dependencies**: getDeals action, DealCard
+ - **UI**: CSS Grid with 6 columns (one per stage)
+
+### Data Flow
+
+```mermaid
+flowchart LR
+ A[Pipeline Board] --> B{Stage Columns}
+ B --> C[Prospecting]
+ B --> D[Qualification]
+ B --> E[Proposal]
+ B --> F[Negotiation]
+ B --> G[Closed Won]
+ B --> H[Closed Lost]
+
+ C --> I[DealCard]
+ D --> I
+ E --> I
+ F --> I
+ G --> I
+ H --> I
+
+ I --> J[Edit Click]
+ I --> K[Stage Dropdown]
+
+ J --> L[DealForm]
+ K --> M[updateDeal Action]
+ M --> N[Revalidate Board]
+```
+
+### Critical Dependencies
+
+**Internal**:
+- Deal schemas from Phase 2
+- Contact schemas (for foreign key and dropdown)
+- Auth utilities (requireAuth, getCurrentUser)
+- DB connection (getDb)
+
+**External**:
+- `react-hook-form` + `zod` - Form validation
+- shadcn/ui components: Card, Input, Select, Textarea, Button, Form, Badge
+- Date picker: Consider shadcn/ui date picker or simple HTML date input for MVP
+
+**Configuration**:
+- None - uses existing D1 binding
+
+### Gotchas & Known Issues
+
+**Contact Dropdown Performance**:
+- Load all user's contacts for dropdown selector
+- If user has >100 contacts, consider search/filter in dropdown (use shadcn/ui Combobox instead of Select)
+- For MVP, simple Select is fine
+
+**Stage Enum Constraint**:
+- Fixed stages defined in schema: `["Prospecting", "Qualification", "Proposal", "Negotiation", "Closed Won", "Closed Lost"]`
+- Enforce in Zod validation AND database constraint
+- Changing stage via dropdown updates deal immediately (no drag-drop for MVP)
+
+**Currency Formatting**:
+- Store value as REAL in database (e.g., 5000.00)
+- Store currency as TEXT (e.g., "USD", "AUD")
+- Display formatted: `new Intl.NumberFormat('en-AU', { style: 'currency', currency: 'AUD' }).format(value)`
+- For MVP, default to AUD (hardcode or make it user preference in Phase 2)
+
+**expectedCloseDate Handling**:
+- Store as INTEGER unix timestamp
+- Use HTML date input (returns YYYY-MM-DD string)
+- Convert to timestamp: `new Date(dateString).getTime()`
+- Display formatted: `new Date(timestamp).toLocaleDateString('en-AU')`
+
+**Board Layout Without Drag-Drop**:
+- Use CSS Grid: `grid-template-columns: repeat(6, 1fr)`
+- Each column filters deals by stage
+- To change stage: Click deal → Edit form → Change stage dropdown → Save
+- Phase 2 feature: Add drag-drop with `@dnd-kit/core`
+
+**Soft Delete for Deals**:
+- Not implemented in MVP
+- Hard delete is fine for learning project
+- Phase 2: Add `deletedAt` column for soft delete
+
+### Tasks
+
+- [ ] Create actions directory and implement all 4 action files
+- [ ] Create Zod schemas for deal create/update (validate currency, stage enum, dates)
+- [ ] Implement getDeals with JOIN to contacts table (include contact name in results)
+- [ ] Create DealForm component with contact Select dropdown (load from getContacts action)
+- [ ] Add currency input field (number input + currency dropdown)
+- [ ] Add expectedCloseDate field (HTML date input)
+- [ ] Add stage Select dropdown with 6 options
+- [ ] Create DealCard component with formatted currency, contact name, stage badge
+- [ ] Create /dashboard/deals page with 6-column grid layout
+- [ ] Filter deals by stage and render in appropriate column
+- [ ] Create /dashboard/deals/new page with DealForm
+- [ ] Create /dashboard/deals/[id]/edit page with DealForm (pre-filled)
+- [ ] Add navigation link in dashboard layout
+- [ ] Test all CRUD operations manually
+
+### Verification Criteria
+
+- [ ] Can create new deal with title, contact, value, currency, stage, expectedCloseDate
+- [ ] Deal appears in correct stage column on board
+- [ ] Can edit deal and change stage (moves to new column after save)
+- [ ] Can delete deal (removes from board)
+- [ ] Contact dropdown shows user's contacts only
+- [ ] Currency displays formatted (e.g., "$5,000.00 AUD")
+- [ ] expectedCloseDate displays formatted (e.g., "15/03/2025")
+- [ ] Cannot edit/delete another user's deals (403 error)
+- [ ] Board layout is responsive (stacks columns on mobile)
+- [ ] Validation catches invalid data (negative value, invalid stage, etc.)
+
+### Exit Criteria
+
+Complete pipeline management system with CRUD and visual Kanban board. Users can create deals linked to contacts, track progress through stages, and view pipeline at a glance. All operations respect user ownership.
+
+---
+
+## Phase 5: Dashboard Integration
+
+**Type**: UI
+**Estimated**: 1 hour
+**Files**: `src/app/dashboard/page.tsx`, `src/components/stat-card.tsx`, `src/modules/dashboard/actions/get-metrics.action.ts`
+
+### Purpose
+
+Enhance dashboard home page with CRM metrics and navigation links. Provides quick overview of contacts and deals.
+
+### File Map
+
+- `src/modules/dashboard/actions/get-metrics.action.ts` (new ~60 lines)
+ - **Purpose**: Server action to compute CRM metrics
+ - **Key exports**: getDashboardMetrics()
+ - **Dependencies**: contact.schema.ts, deal.schema.ts, getDb(), requireAuth()
+ - **Returns**: Object with totalContacts, activeDeals, pipelineValue, contactsByTag
+
+- `src/components/stat-card.tsx` (new ~40 lines)
+ - **Purpose**: Reusable metric display card
+ - **Key exports**: StatCard component
+ - **Dependencies**: shadcn/ui (Card)
+ - **Props**: title, value, icon, trend (optional)
+
+- `src/app/dashboard/page.tsx` (modify existing ~40 lines added)
+ - **Purpose**: Dashboard home page
+ - **Modifications**: Add CRM metrics grid, navigation cards
+ - **Dependencies**: getDashboardMetrics action, StatCard
+
+- `src/app/dashboard/layout.tsx` (modify existing ~10 lines added)
+ - **Purpose**: Dashboard sidebar navigation
+ - **Modifications**: Add links to /dashboard/contacts and /dashboard/deals
+ - **Dependencies**: None
+
+### Data Flow
+
+```mermaid
+flowchart TB
+ A[Dashboard Page] --> B[getDashboardMetrics Action]
+ B --> C{Query D1}
+ C --> D[COUNT contacts by user]
+ C --> E[COUNT deals WHERE stage NOT IN closed]
+ C --> F[SUM deal values]
+ C --> G[GROUP contacts by tags]
+
+ D --> H[Metrics Object]
+ E --> H
+ F --> H
+ G --> H
+
+ H --> I[Render StatCards]
+ I --> J[Total Contacts]
+ I --> K[Active Deals]
+ I --> L[Pipeline Value]
+```
+
+### Critical Dependencies
+
+**Internal**:
+- Contact and deal schemas
+- Auth utilities (requireAuth)
+- DB connection (getDb)
+
+**External**:
+- shadcn/ui Card component
+- Lucide icons for StatCard icons
+
+**Configuration**:
+- None
+
+### Gotchas & Known Issues
+
+**Metric Computation Performance**:
+- Use COUNT(*) for counts (fast)
+- Use SUM(value) for pipeline value (fast)
+- Avoid fetching all records then counting in JS (slow)
+- Add indexes if queries are slow: `CREATE INDEX idx_deals_user_stage ON deals(userId, stage)`
+
+**Active Deals Definition**:
+- Active = stage NOT IN ('Closed Won', 'Closed Lost')
+- SQL: `SELECT COUNT(*) FROM deals WHERE userId = ? AND stage NOT IN ('Closed Won', 'Closed Lost')`
+
+**Pipeline Value Calculation**:
+- Sum only active deals (exclude closed)
+- Handle multiple currencies: For MVP, assume all AUD and sum directly
+- Phase 2: Convert currencies to base currency using exchange rates
+
+**Dashboard Layout**:
+- Use CSS Grid for metrics cards: `grid-template-columns: repeat(auto-fit, minmax(250px, 1fr))`
+- Responsive: Cards wrap on mobile
+
+**Navigation Cards vs Sidebar**:
+- For MVP, add simple navigation links in sidebar
+- Phase 2: Add quick action cards (e.g., "Add Contact" button with icon)
+
+### Tasks
+
+- [ ] Create getDashboardMetrics action with COUNT and SUM queries
+- [ ] Compute totalContacts (all contacts for user)
+- [ ] Compute activeDeals (deals with stage not Closed Won/Lost)
+- [ ] Compute pipelineValue (SUM of active deal values)
+- [ ] Create StatCard component with props: title, value, icon
+- [ ] Modify /dashboard page to fetch metrics and render 3 StatCards
+- [ ] Add CSS Grid layout for metrics cards
+- [ ] Modify dashboard layout to add navigation links (Contacts, Deals)
+- [ ] Add Lucide icons to sidebar links (Users icon for Contacts, Briefcase icon for Deals)
+- [ ] Test metrics update after creating/deleting contacts/deals
+
+### Verification Criteria
+
+- [ ] Dashboard shows correct total contacts count
+- [ ] Dashboard shows correct active deals count
+- [ ] Dashboard shows correct pipeline value (formatted currency)
+- [ ] Metrics update immediately after CRUD operations (revalidation works)
+- [ ] StatCards are responsive and wrap on mobile
+- [ ] Navigation links work (click Contacts → /dashboard/contacts)
+- [ ] Sidebar highlights current route
+- [ ] Icons render correctly
+
+### Exit Criteria
+
+Dashboard provides useful CRM overview with metrics. Users can navigate to contacts and deals from dashboard. Metrics accurately reflect current data and update in real-time.
+
+---
+
+## Phase 6: Testing & Documentation
+
+**Type**: Testing + Documentation
+**Estimated**: 1 hour
+**Files**: `src/lib/seed.ts`, `docs/DATABASE_SCHEMA.md` (update), `docs/TESTING.md` (new), `README.md` (update)
+
+### Purpose
+
+Create seed data for testing, verify all features work end-to-end, and document the CRM implementation.
+
+### File Map
+
+- `src/lib/seed.ts` (new ~100 lines)
+ - **Purpose**: Seed script to populate D1 with test data
+ - **Key exports**: seedCRM() function
+ - **Dependencies**: Contact/deal schemas, getDb()
+ - **Creates**: 10 contacts, 5 tags, 5 deals across all stages
+
+- `docs/TESTING.md` (new ~80 lines)
+ - **Purpose**: Test plan and manual testing checklist
+ - **Contains**: Feature checklist, edge cases, security tests
+
+- `docs/DATABASE_SCHEMA.md` (update existing)
+ - **Modifications**: Add CRM tables documentation from Phase 2
+ - **Already created in Phase 2 planning** - just verify it's accurate
+
+- `README.md` (modify existing ~30 lines added)
+ - **Modifications**: Add CRM features section, setup instructions
+ - **Contains**: Feature list, screenshots (optional), usage guide
+
+### Seed Data
+
+Create realistic test data:
+
+**Contacts** (10 total):
+- 3 with multiple tags
+- 2 with deals
+- 5 with just basic info
+- Mix of complete and minimal profiles
+
+**Tags** (5 total):
+- "Customer" (green)
+- "Lead" (blue)
+- "Partner" (purple)
+- "Inactive" (gray)
+- "VIP" (gold)
+
+**Deals** (5 total):
+- 1 in Prospecting ($5,000)
+- 1 in Qualification ($12,000)
+- 1 in Proposal ($25,000)
+- 1 in Closed Won ($50,000)
+- 1 in Closed Lost ($8,000)
+
+### Testing Checklist
+
+**Contacts**:
+- [ ] Create contact with all fields filled
+- [ ] Create contact with only firstName
+- [ ] Edit contact and change email
+- [ ] Delete contact (verify deals set contactId to null)
+- [ ] Search for contact by firstName
+- [ ] Search for contact by email
+- [ ] Search for contact by company (case-insensitive)
+- [ ] Create tag and assign to contact
+- [ ] Assign multiple tags to one contact
+- [ ] Filter contacts by tag
+- [ ] Remove tag from contact
+- [ ] Delete tag (verify junction table cleaned up)
+
+**Deals**:
+- [ ] Create deal linked to contact
+- [ ] Create deal with no contact (contactId null)
+- [ ] Edit deal and change stage (verify moves to correct column)
+- [ ] Edit deal and change contact
+- [ ] Delete deal
+- [ ] View pipeline board (all 6 columns visible)
+- [ ] Verify currency formatting displays correctly
+- [ ] Verify expectedCloseDate displays formatted
+
+**Dashboard**:
+- [ ] Metrics show correct counts
+- [ ] Create contact → metric updates
+- [ ] Delete deal → pipeline value updates
+- [ ] Click navigation links (Contacts, Deals)
+
+**Security**:
+- [ ] Cannot view another user's contacts (if multi-user test)
+- [ ] Cannot edit another user's contacts
+- [ ] Cannot delete another user's deals
+
+**UI/UX**:
+- [ ] Forms validate before submit (invalid email caught)
+- [ ] Success toasts appear after create/update/delete
+- [ ] Responsive layout works on mobile (test at 375px width)
+- [ ] No console errors in browser DevTools
+
+### Tasks
+
+- [ ] Create seed script in src/lib/seed.ts
+- [ ] Add npm script to package.json: `"db:seed": "tsx src/lib/seed.ts"`
+- [ ] Run seed script locally: `pnpm run db:seed`
+- [ ] Verify seed data appears in dashboard
+- [ ] Create TESTING.md with manual test checklist
+- [ ] Run through entire test checklist, check off items
+- [ ] Fix any bugs found during testing
+- [ ] Update README.md with CRM features section
+- [ ] Add setup instructions for new developers
+- [ ] Verify docs/DATABASE_SCHEMA.md is complete
+- [ ] Take screenshots of key pages (optional but helpful)
+- [ ] Create git commit for all testing/docs changes
+
+### Verification Criteria
+
+- [ ] Seed script runs without errors
+- [ ] All 10 contacts, 5 tags, 5 deals created
+- [ ] All items in testing checklist pass
+- [ ] No console errors during testing
+- [ ] README.md accurately describes CRM features
+- [ ] TESTING.md documents all test cases
+- [ ] Documentation is ready for handoff/deployment
+
+### Exit Criteria
+
+All CRM features tested and verified working. Seed data available for demos. Documentation complete and accurate. Project ready for deployment or Phase 2 feature additions.
+
+---
+
+## Notes
+
+### Testing Strategy
+
+**Per-phase verification** (inline): Each phase has verification criteria that must pass before moving to next phase. This catches issues early.
+
+**Final testing phase** (Phase 6): Comprehensive end-to-end testing with seed data. Ensures all features work together and no integration issues.
+
+### Deployment Strategy
+
+**Local development first** (all 6 phases): Build and test everything locally before deploying to Cloudflare.
+
+**Deploy when ready**: After Phase 6 verification passes:
+1. Create production D1 database: `npx wrangler d1 create fullstack-crm`
+2. Update wrangler.jsonc with production database ID
+3. Run production migrations: `pnpm run db:migrate`
+4. Deploy: `pnpm run build && npx wrangler deploy`
+5. Set up GitHub Actions for future deployments (optional)
+
+### Context Management
+
+**Phases are sized for single sessions**:
+- Phase 1-2: Quick setup (can do together)
+- Phase 3: Largest phase (~2.5 hours), may need context clear mid-phase
+- Phase 4-6: Moderate phases (can each fit in one session)
+
+**If context gets full mid-phase**:
+- Use SESSION.md to track current task (see Session Handoff Protocol)
+- Create git checkpoint commit
+- Resume from SESSION.md after context clear
+
+### Phase 2 Feature Ideas
+
+After MVP is complete, consider adding:
+- **Activity Timeline**: Log calls, meetings, emails on contacts
+- **Avatar Uploads**: R2 storage for contact photos (reuse todos pattern)
+- **Custom Deal Stages**: User-defined pipeline stages (requires new table)
+- **Drag-and-Drop Kanban**: Use `@dnd-kit/core` for pipeline board
+- **Advanced Search**: Full-text search with Vectorize (semantic search)
+- **Email Integration**: Cloudflare Email Routing + Resend for sending
+- **Export/Import**: CSV export of contacts/deals
+- **Analytics Dashboard**: Charts with Recharts (conversion rates, pipeline trends)
diff --git a/src/modules/todos/actions/create-todo.action.ts b/src/modules/todos/actions/create-todo.action.ts
index 3024614..f306b5d 100644
--- a/src/modules/todos/actions/create-todo.action.ts
+++ b/src/modules/todos/actions/create-todo.action.ts
@@ -1,5 +1,6 @@
"use server";
+import { cookies } from "next/headers";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { getDb } from "@/db";
@@ -60,6 +61,12 @@ export async function createTodoAction(formData: FormData) {
} else {
// Log error but don't fail the todo creation
console.error("Image upload failed:", uploadResult.error);
+ // Set a cookie to notify the client about the upload failure
+ const cookieStore = await cookies();
+ cookieStore.set("todo-warning", "Image upload failed, but todo was created successfully", {
+ path: "/",
+ maxAge: 10, // 10 seconds - just enough for redirect
+ });
}
}
diff --git a/src/modules/todos/actions/update-todo.action.ts b/src/modules/todos/actions/update-todo.action.ts
index 99e4c6a..1c29390 100644
--- a/src/modules/todos/actions/update-todo.action.ts
+++ b/src/modules/todos/actions/update-todo.action.ts
@@ -1,6 +1,7 @@
"use server";
import { and, eq } from "drizzle-orm";
+import { cookies } from "next/headers";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { getDb } from "@/db";
@@ -52,6 +53,12 @@ export async function updateTodoAction(todoId: number, formData: FormData) {
imageAlt = validatedData.imageAlt || file.name;
} else {
console.error("Image upload failed:", uploadResult.error);
+ // Set a cookie to notify the client about the upload failure
+ const cookieStore = await cookies();
+ cookieStore.set("todo-warning", "Image upload failed, but todo was updated successfully", {
+ path: "/",
+ maxAge: 10, // 10 seconds - just enough for redirect
+ });
}
}
diff --git a/src/modules/todos/components/warning-toast.tsx b/src/modules/todos/components/warning-toast.tsx
new file mode 100644
index 0000000..06be804
--- /dev/null
+++ b/src/modules/todos/components/warning-toast.tsx
@@ -0,0 +1,21 @@
+"use client";
+
+import { useEffect } from "react";
+import toast from "react-hot-toast";
+
+interface WarningToastProps {
+ warning: string | null;
+}
+
+export function WarningToast({ warning }: WarningToastProps) {
+ useEffect(() => {
+ if (warning) {
+ toast.error(warning, {
+ duration: 6000, // Show warning for 6 seconds
+ icon: "⚠️",
+ });
+ }
+ }, [warning]);
+
+ return null; // This component doesn't render anything
+}
diff --git a/src/modules/todos/todo-list.page.tsx b/src/modules/todos/todo-list.page.tsx
index 0939b82..53bca45 100644
--- a/src/modules/todos/todo-list.page.tsx
+++ b/src/modules/todos/todo-list.page.tsx
@@ -1,15 +1,28 @@
import { Plus } from "lucide-react";
import Link from "next/link";
+import { cookies } from "next/headers";
import { Button } from "@/components/ui/button";
import getAllTodos from "@/modules/todos/actions/get-todos.action";
import { TodoCard } from "@/modules/todos/components/todo-card";
+import { WarningToast } from "@/modules/todos/components/warning-toast";
import todosRoutes from "./todos.route";
export default async function TodoListPage() {
const todos = await getAllTodos();
+ // Check for warning cookie
+ const cookieStore = await cookies();
+ const warningCookie = cookieStore.get("todo-warning");
+ const warning = warningCookie?.value || null;
+
+ // Clear the cookie after reading
+ if (warningCookie) {
+ cookieStore.delete("todo-warning");
+ }
+
return (
<>
+