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 ( <> +

Todos