From 386e6f3cc72750022f1fb2ac5a765b1954a25984 Mon Sep 17 00:00:00 2001 From: Jez Date: Sat, 8 Nov 2025 14:51:19 +1100 Subject: [PATCH 01/28] fix: auto-detect port in auth client baseURL - Replace hardcoded localhost:3000 with window.location.origin - Enables auth to work on any port (3000, 3001, etc.) - Fixes issue when port 3000 is already in use - Maintains SSR compatibility with fallback env var --- src/modules/auth/utils/auth-client.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/modules/auth/utils/auth-client.ts b/src/modules/auth/utils/auth-client.ts index c06fe9f..7923257 100644 --- a/src/modules/auth/utils/auth-client.ts +++ b/src/modules/auth/utils/auth-client.ts @@ -4,9 +4,7 @@ import { createAuthClient } from "better-auth/react"; // The baseURL will be automatically determined from the current origin export const authClient = createAuthClient({ baseURL: - process.env.NODE_ENV === "development" - ? "http://localhost:3000" - : typeof window !== "undefined" - ? window.location.origin - : "", + typeof window !== "undefined" + ? window.location.origin // Auto-detect from browser URL (works for any port) + : process.env.NEXT_PUBLIC_AUTH_URL || "http://localhost:3000", // Fallback for SSR }); From f389b91e6e7e3f373b8e94898ab7b786ebdd27e1 Mon Sep 17 00:00:00 2001 From: Jez Date: Sat, 8 Nov 2025 14:58:48 +1100 Subject: [PATCH 02/28] fix: correct todos navigation link path - Change /todos to /dashboard/todos to match actual route - Fixes 404 error when clicking Todos in navigation - Actual route is in app/dashboard/todos/page.tsx --- src/components/navigation.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/navigation.tsx b/src/components/navigation.tsx index a6a3b0f..9595f27 100644 --- a/src/components/navigation.tsx +++ b/src/components/navigation.tsx @@ -22,7 +22,7 @@ export function Navigation() { Home - + From e5e4bb39e575415e044177bf10993edf9502480a Mon Sep 17 00:00:00 2001 From: Jez Date: Sat, 8 Nov 2025 15:03:08 +1100 Subject: [PATCH 06/28] fix: add file size and type validation for image uploads - Validate image files are under 5MB before upload - Validate only PNG/JPG files are accepted - Show toast error messages for validation failures - Reset file input on validation error - Prevents large file uploads that would fail - Matches documented limits ("PNG, JPG up to 5MB") --- src/modules/todos/components/todo-form.tsx | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/modules/todos/components/todo-form.tsx b/src/modules/todos/components/todo-form.tsx index d26d514..9417c4a 100644 --- a/src/modules/todos/components/todo-form.tsx +++ b/src/modules/todos/components/todo-form.tsx @@ -5,6 +5,7 @@ import { Upload, X } from "lucide-react"; import Image from "next/image"; import { useState, useTransition } from "react"; import { useForm } from "react-hook-form"; +import toast from "react-hot-toast"; import type { z } from "zod"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; @@ -108,6 +109,22 @@ export function TodoForm({ const handleImageChange = (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (file) { + // Validate file size (5MB = 5 * 1024 * 1024 bytes) + const maxSize = 5 * 1024 * 1024; + if (file.size > maxSize) { + toast.error("Image must be less than 5MB"); + e.target.value = ""; // Reset input + return; + } + + // Validate file type + const validTypes = ["image/png", "image/jpeg", "image/jpg"]; + if (!validTypes.includes(file.type)) { + toast.error("Only PNG and JPG images are allowed"); + e.target.value = ""; // Reset input + return; + } + setImageFile(file); const reader = new FileReader(); reader.onloadend = () => { From b0bcb4d28a7d6292ac54ad88c130aa27511f4063 Mon Sep 17 00:00:00 2001 From: Jez Date: Sat, 8 Nov 2025 15:08:37 +1100 Subject: [PATCH 07/28] fix: prevent double https:// in R2 public URLs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CLOUDFLARE_R2_URL environment variable already includes the protocol prefix (https://), so prepending another 'https://' results in malformed URLs like 'https://https://...'. This fix removes the hardcoded protocol prefix and uses the env var value directly. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/lib/r2.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/lib/r2.ts b/src/lib/r2.ts index 2fdc4f8..a3c1812 100644 --- a/src/lib/r2.ts +++ b/src/lib/r2.ts @@ -46,7 +46,8 @@ export async function uploadToR2( } // Return public URL of R2 (should be using custom domain) - const publicUrl = `https://${(env as any).CLOUDFLARE_R2_URL}/${key}`; + // CLOUDFLARE_R2_URL already includes the protocol (https://) + const publicUrl = `${(env as any).CLOUDFLARE_R2_URL}/${key}`; return { success: true, From b5c79d7c4fb4601e47f034d68408382e579890d4 Mon Sep 17 00:00:00 2001 From: Jez Date: Sat, 8 Nov 2025 15:11:27 +1100 Subject: [PATCH 08/28] refactor: replace hardcoded database ID with environment variable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace hardcoded D1 database ID in drizzle.config.ts with CLOUDFLARE_D1_DATABASE_ID environment variable for better configurability across different environments. Changes: - Add CLOUDFLARE_D1_DATABASE_ID to .dev.vars.example - Update drizzle.config.ts to use env var instead of hardcoded ID - Document the new env var in README.md with instructions - Add note about copying database_id when creating D1 database This allows developers to easily switch between different databases (dev/staging/prod) without modifying code. Note: wrangler.jsonc still requires the actual database_id value as it doesn't support environment variable interpolation. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .dev.vars.example | 2 + PROJECT_BRIEF.md | 310 ++++++++++++++++++++++++++++++++++++++++++++++ README.md | 7 ++ drizzle.config.ts | 2 +- 4 files changed, 320 insertions(+), 1 deletion(-) create mode 100644 PROJECT_BRIEF.md diff --git a/.dev.vars.example b/.dev.vars.example index b97e98a..6536148 100644 --- a/.dev.vars.example +++ b/.dev.vars.example @@ -4,7 +4,9 @@ NEXTJS_ENV=development # Drizzle Kit credentials for D1 # Get your Account ID from: https://dash.cloudflare.com/ (right sidebar) # Get your API Token from: https://dash.cloudflare.com/profile/api-tokens +# Get your Database ID from: wrangler d1 list (or create with: wrangler d1 create ) CLOUDFLARE_ACCOUNT_ID=your-account-id-here +CLOUDFLARE_D1_DATABASE_ID=your-database-id-here CLOUDFLARE_D1_TOKEN=your-api-token-here CLOUDFLARE_R2_URL=your-r2-url-here CLOUDFLARE_API_TOKEN=your-api-token-here diff --git a/PROJECT_BRIEF.md b/PROJECT_BRIEF.md new file mode 100644 index 0000000..4f9714a --- /dev/null +++ b/PROJECT_BRIEF.md @@ -0,0 +1,310 @@ +# Project Brief: Fullstack Next.js + Cloudflare CRM + +**Created**: 2025-11-08 +**Status**: Ready for Planning +**Purpose**: Learning exercise to understand Next.js 15 + Cloudflare Workers integration + +--- + +## Vision + +A lightweight CRM built on the fullstack-next-cloudflare template to learn modern fullstack patterns: Next.js App Router, Cloudflare D1/R2, Server Actions, and module-sliced architecture. + +--- + +## Problem/Opportunity + +**Learning objective**: Understand how to build production-grade features on Cloudflare's edge platform by implementing real-world CRM functionality. + +**Why CRM as the learning vehicle**: +- Multi-entity relationships (contacts ↔ deals) +- CRUD operations with validation +- Data modeling with foreign keys +- UI patterns (lists, forms, boards) +- Follows existing architecture (todos module) + +--- + +## Target Audience + +- **Primary user**: You (Jez) - exploring the stack +- **Scale**: Single user for learning (no multi-tenancy needed) +- **Context**: Educational project, not production SaaS +- **Data**: Can use synthetic/test data + +--- + +## Core Functionality (MVP) + +### 1. Contacts Module +**Essential**: +- ✅ Create, read, update, delete contacts +- ✅ Fields: firstName, lastName, email, phone, company, jobTitle, notes +- ✅ Search/filter by name, email, company +- ✅ Tag system (many-to-many: contacts ↔ tags) +- ✅ User-specific tags with colors + +**Deferred to Phase 2** (keep MVP lean): +- ❌ Activity timeline (calls, meetings, notes) +- ❌ Avatar uploads to R2 +- ❌ Email integration +- ❌ Import/export + +### 2. Deals/Pipeline Module +**Essential**: +- ✅ Create, read, update, delete deals +- ✅ Fields: title, value, currency, stage, expectedCloseDate, description +- ✅ Link deal to contact (simple 1:1 relationship) +- ✅ Pipeline board view (simple columns by stage) +- ✅ Fixed stages: Prospecting → Qualification → Proposal → Negotiation → Closed Won/Lost + +**Deferred to Phase 2**: +- ❌ Custom user-defined stages +- ❌ Drag-and-drop stage changes +- ❌ Deal probability/forecasting +- ❌ Multiple contacts per deal + +### 3. Dashboard Integration +**Essential**: +- ✅ Add navigation to /dashboard/contacts and /dashboard/deals +- ✅ Simple metrics cards (total contacts, active deals, pipeline value) + +**Deferred**: +- ❌ Charts/graphs +- ❌ Activity feed +- ❌ Advanced analytics + +--- + +## Tech Stack (Validated) + +Uses existing template stack - no changes needed: + +- **Frontend**: Next.js 15.4.6 (App Router) + React 19 + TypeScript +- **UI**: Tailwind v4 + shadcn/ui + Lucide icons +- **Backend**: Cloudflare Workers with Static Assets (@opennextjs/cloudflare) +- **Database**: Cloudflare D1 (SQLite) with Drizzle ORM +- **Storage**: Cloudflare R2 (not using for MVP - deferred avatars) +- **Auth**: Better Auth (already configured) +- **Forms**: React Hook Form + Zod validation +- **Deployment**: Cloudflare Workers (via GitHub Actions) + +**Why this stack works for learning**: +- ✅ Modern patterns (Server Actions, RSC) +- ✅ Edge-first architecture +- ✅ Type-safe end-to-end (TypeScript + Drizzle + Zod) +- ✅ Template already has auth, DB, migrations configured +- ✅ Follows module-sliced pattern (easy to extend) + +--- + +## Research Findings + +### Existing Template Analysis + +**What's already built** (from /home/jez/Documents/fullstack-next-cloudflare-demo): +- ✅ **Auth module**: Better Auth with email/password + Google OAuth +- ✅ **Todos module**: Complete CRUD example with categories, priorities, status +- ✅ **Database setup**: D1 + Drizzle + migrations working +- ✅ **R2 integration**: Image upload pattern (in todos for cover images) +- ✅ **Module architecture**: `src/modules/[feature]/` with actions/, components/, schemas/ +- ✅ **UI components**: 13 shadcn/ui components configured +- ✅ **Deployment**: GitHub Actions workflow ready + +**Pattern to follow**: +The `src/modules/todos/` structure is the perfect blueprint: +``` +todos/ +├── actions/ # Server actions (create, get, update, delete) +├── components/ # UI components (form, card, list) +├── models/ # Enums and types +└── schemas/ # Drizzle + Zod schemas +``` + +We'll replicate this for `contacts/` and `deals/` modules. + +### Technical Validation + +**✅ D1 Relational Data**: +- Drizzle ORM supports foreign keys and joins +- Template already has `todos → categories` relationship +- Contacts ↔ Deals will work the same way + +**✅ Many-to-Many Tags**: +- Need junction table: `contacts_to_tags` +- Drizzle example in their docs: https://orm.drizzle.team/docs/rqb#many-to-many + +**✅ Server Actions Performance**: +- Template uses server actions for all mutations +- Edge runtime = fast globally +- No API route boilerplate needed + +**Known Challenges**: +1. **Junction table queries** - Drizzle syntax for many-to-many can be verbose + - Mitigation: Study existing `todos.categoryId` pattern, extend to junction table +2. **Pipeline board UI** - Kanban layout without drag-drop library + - Mitigation: Simple CSS Grid columns, manual stage update dropdown (defer drag-drop to Phase 2) +3. **Search implementation** - D1 doesn't have full-text search + - Mitigation: Use SQL `LIKE` queries for MVP (good enough for learning) + +--- + +## Scope Validation + +### Why Build This? +**Learning objectives met**: +- ✅ Practice module-sliced architecture +- ✅ Understand Drizzle ORM relationships (1:1, many-to-many) +- ✅ Learn Server Actions data mutation patterns +- ✅ Explore D1 migrations workflow +- ✅ Build complex forms with validation +- ✅ Create dashboard visualizations +- ✅ Deploy to Cloudflare edge + +**Why NOT use existing CRM**: +- This is about learning the stack, not production use +- Building from scratch teaches architectural patterns +- Template provides 80% foundation (auth, DB, UI), we add 20% (domain logic) + +### Why This Scope? +**MVP is deliberately minimal** to focus on learning core patterns: +- 2 main entities (contacts, deals) = practice relationships +- Tags system = practice many-to-many +- Pipeline board = practice UI state management +- Dashboard metrics = practice aggregations + +**Deferred features** prevent scope creep: +- Activity logging (complex timeline UI) +- Avatars (R2 already demonstrated in todos) +- Custom stages (adds complexity) +- Advanced analytics (not core learning) + +**Time investment** = ~6-8 hours (~6-8 minutes with Claude Code) +- Realistic for learning project +- Can complete in 1-2 sessions +- Leaves room for experimentation + +### What Could Go Wrong? + +**Risk 1: Overcomplicating relationships** +- *What*: Trying to add too many foreign keys (deals → contacts → companies → industries...) +- *Mitigation*: Stick to MVP scope (contacts ↔ tags, deals → contacts). No nested hierarchies. + +**Risk 2: UI perfectionism** +- *What*: Spending hours on drag-and-drop Kanban, animations, etc. +- *Mitigation*: Use simple table/grid layouts. Focus on functionality, not polish. + +**Risk 3: Scope creep during build** +- *What*: "While I'm here, let me add email integration..." +- *Mitigation*: Strict adherence to MVP checklist. Document ideas for Phase 2. + +--- + +## Estimated Effort + +**Total MVP**: ~6-8 hours (~6-8 minutes human time with Claude Code) + +**Breakdown**: +- Setup (clone, configure D1, run migrations): 30 min +- Contacts module (schema, actions, UI, tags): 2.5 hours +- Deals module (schema, actions, UI, board): 2 hours +- Dashboard integration (nav, metrics): 1 hour +- Testing & seed data: 1 hour +- Documentation: 30 min + +**Phase 2** (optional extensions): +- Activity timeline: +2 hours +- Avatar uploads: +1 hour +- Drag-drop Kanban: +2 hours +- Custom stages: +1.5 hours +- Advanced search: +2 hours + +--- + +## Success Criteria (MVP) + +**Functional Requirements**: +- [ ] Can create, edit, delete, search contacts +- [ ] Can assign multiple tags to contacts +- [ ] Can create tags with colors +- [ ] Can create, edit, delete deals +- [ ] Deals link to contacts (dropdown selector) +- [ ] Pipeline board shows deals in columns by stage +- [ ] Dashboard shows: total contacts, active deals, pipeline value +- [ ] All data isolated to logged-in user +- [ ] Forms have proper validation (Zod schemas) +- [ ] UI responsive on mobile/desktop + +**Technical Requirements**: +- [ ] Follows module-sliced architecture (`src/modules/contacts/`, `src/modules/deals/`) +- [ ] Uses Server Actions (not API routes) +- [ ] Database migrations run successfully (local + production) +- [ ] Type-safe end-to-end (TypeScript + Drizzle + Zod) +- [ ] shadcn/ui components used consistently +- [ ] Deploys to Cloudflare Workers without errors + +**Learning Objectives**: +- [ ] Understand how to structure multi-entity features +- [ ] Practice Drizzle ORM relationships (foreign keys, joins, many-to-many) +- [ ] Learn Server Actions patterns for CRUD +- [ ] Experience D1 migrations workflow +- [ ] Build complex forms with React Hook Form + Zod + +--- + +## Next Steps + +### If Proceeding (Recommended) + +1. **Exit plan mode** and start implementation +2. **Clone project** to `/home/jez/Documents/fullstack-next-cloudflare-crm` +3. **Configure local Cloudflare**: + - Create new D1 database: `npx wrangler d1 create fullstack-crm` + - Update `wrangler.jsonc` with new database ID + - Set up `.dev.vars` with Better Auth secrets +4. **Implement in phases**: + - Phase 1: Project setup + database schema + - Phase 2: Contacts module + - Phase 3: Deals module + - Phase 4: Dashboard integration + - Phase 5: Testing & documentation +5. **Deploy when ready** (Cloudflare account setup) + +### If Refining Scope + +**Want simpler?** +- Skip tags (just contacts + deals) +- Skip pipeline board (simple table view) +- Reduces to ~4 hours + +**Want more ambitious?** +- Add activity timeline +- Add R2 avatar uploads +- Add custom stages +- Increases to ~10-12 hours + +--- + +## Research References + +- **Template repo**: https://github.com/jezweb/fullstack-next-cloudflare (forked from ifindev) +- **Local codebase**: /home/jez/Documents/fullstack-next-cloudflare-demo +- **Drizzle ORM relationships**: https://orm.drizzle.team/docs/rqb +- **shadcn/ui components**: https://ui.shadcn.com/docs +- **Cloudflare D1 docs**: via `mcp__cloudflare-docs__search_cloudflare_documentation` +- **Relevant skills**: `~/.claude/skills/cloudflare-d1`, `~/.claude/skills/drizzle-orm-d1` + +--- + +## Recommendation + +✅ **Proceed with MVP implementation** + +**Rationale**: +1. Scope is well-defined and realistic (6-8 hours) +2. Template provides solid foundation (80% already built) +3. Learning objectives are clear and achievable +4. No technical blockers (all patterns exist in template) +5. Can defer advanced features to Phase 2 without compromising learning + +**This is an excellent learning project** - complex enough to teach real patterns, simple enough to complete without frustration. diff --git a/README.md b/README.md index 333f7ab..0feb729 100644 --- a/README.md +++ b/README.md @@ -149,6 +149,7 @@ Edit `.dev.vars` with your credentials: ```bash # Cloudflare Configuration CLOUDFLARE_ACCOUNT_ID=your-account-id +CLOUDFLARE_D1_DATABASE_ID=your-database-id CLOUDFLARE_D1_TOKEN=your-api-token # Authentication Secrets @@ -263,6 +264,10 @@ wrangler d1 create your-app-name # Output will show: # database_name = "your-app-name" # database_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" +# +# Copy the database_id - you'll need it for: +# - wrangler.jsonc (d1_databases.database_id) +# - .dev.vars (CLOUDFLARE_D1_DATABASE_ID) ``` **Create R2 Bucket:** @@ -329,6 +334,7 @@ openssl rand -base64 32 ```bash # .dev.vars for local development CLOUDFLARE_ACCOUNT_ID=your-account-id +CLOUDFLARE_D1_DATABASE_ID=your-database-id CLOUDFLARE_D1_TOKEN=your-api-token BETTER_AUTH_SECRET=your-generated-secret GOOGLE_CLIENT_ID=your-google-client-id.apps.googleusercontent.com @@ -403,6 +409,7 @@ Go to your GitHub repository → Settings → Secrets and add: - `CLOUDFLARE_API_TOKEN` - Your API token from Step 2 - `CLOUDFLARE_ACCOUNT_ID` - Your account ID +- `CLOUDFLARE_D1_DATABASE_ID` - Your D1 database ID (from `wrangler d1 create` output) - `BETTER_AUTH_SECRET` - Your auth secret - `GOOGLE_CLIENT_ID` - Your Google client ID - `GOOGLE_CLIENT_SECRET` - Your Google client secret diff --git a/drizzle.config.ts b/drizzle.config.ts index b3c0c64..30fb1aa 100644 --- a/drizzle.config.ts +++ b/drizzle.config.ts @@ -13,7 +13,7 @@ export default defineConfig({ driver: "d1-http", dbCredentials: { accountId: process.env.CLOUDFLARE_ACCOUNT_ID!, - databaseId: "757a32d1-5779-4f09-bcf3-b268013395d4", + databaseId: process.env.CLOUDFLARE_D1_DATABASE_ID!, token: process.env.CLOUDFLARE_D1_TOKEN!, }, }); From ba9b4fc4ea4f8e4c675ad68cebd50393e9802ccc Mon Sep 17 00:00:00 2001 From: Jez Date: Sat, 8 Nov 2025 15:12:49 +1100 Subject: [PATCH 09/28] fix: add NEXT_REDIRECT error handling to update todo action MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add proper handling for Next.js redirect errors in updateTodoAction to match the pattern used in createTodoAction. Without this, redirect() calls from server actions could be incorrectly logged as errors. Changes: - Check if error message is 'NEXT_REDIRECT' before error logging - Re-throw NEXT_REDIRECT errors unchanged to allow proper redirection - Prevents false error logs in console for successful operations This matches the existing pattern in create-todo.action.ts for consistency. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/modules/todos/actions/update-todo.action.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/modules/todos/actions/update-todo.action.ts b/src/modules/todos/actions/update-todo.action.ts index 99e4c6a..bbdf1d4 100644 --- a/src/modules/todos/actions/update-todo.action.ts +++ b/src/modules/todos/actions/update-todo.action.ts @@ -86,6 +86,11 @@ export async function updateTodoAction(todoId: number, formData: FormData) { revalidatePath(todosRoutes.list); redirect(todosRoutes.list); } catch (error) { + // Handle Next.js redirect errors - these are not actual errors + if (error instanceof Error && error.message === "NEXT_REDIRECT") { + throw error; // Re-throw redirect errors as-is + } + console.error("Error updating todo:", error); if ( From 3acc7410d2863af1b64f420599568f02148a9260 Mon Sep 17 00:00:00 2001 From: Jez Date: Sat, 8 Nov 2025 15:15:46 +1100 Subject: [PATCH 10/28] refactor: standardize createCategory error response pattern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update createCategory action to return success/error object instead of throwing errors, making it consistent with other mutation actions like deleteTodoAction and updateTodoFieldAction. Changes: - create-category.action.ts: Return { success, data?, error? } instead of throwing - add-category.tsx: Update to handle new response structure without try/catch - Added authentication error handling for consistency Benefits: - Consistent error handling across all non-form mutation actions - Cleaner client code (no try/catch needed) - Better error propagation to UI - Matches established pattern used by delete and update field actions This brings all programmatic mutations to use the same response pattern, while form actions with redirect() continue to use throw pattern (as required by Next.js). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../todos/actions/create-category.action.ts | 38 +++++++++++++++---- src/modules/todos/components/add-category.tsx | 21 ++++------ 2 files changed, 38 insertions(+), 21 deletions(-) diff --git a/src/modules/todos/actions/create-category.action.ts b/src/modules/todos/actions/create-category.action.ts index 48eaa2c..5890a19 100644 --- a/src/modules/todos/actions/create-category.action.ts +++ b/src/modules/todos/actions/create-category.action.ts @@ -10,7 +10,11 @@ import { } from "@/modules/todos/schemas/category.schema"; import todosRoutes from "../todos.route"; -export async function createCategory(data: unknown): Promise { +export async function createCategory(data: unknown): Promise<{ + success: boolean; + data?: Category; + error?: string; +}> { try { const user = await requireAuth(); const validatedData = insertCategorySchema.parse({ @@ -30,21 +34,39 @@ export async function createCategory(data: unknown): Promise { .returning(); if (!result[0]) { - throw new Error("Failed to create category"); + return { + success: false, + error: "Failed to create category", + }; } // Revalidate pages that might show categories revalidatePath(todosRoutes.list); revalidatePath(todosRoutes.new); - return result[0]; + return { + success: true, + data: result[0], + }; } catch (error) { console.error("Error creating category:", error); - throw new Error( - error instanceof Error - ? error.message - : "Failed to create category", - ); + if ( + error instanceof Error && + error.message === "Authentication required" + ) { + return { + success: false, + error: "Authentication required", + }; + } + + return { + success: false, + error: + error instanceof Error + ? error.message + : "Failed to create category", + }; } } diff --git a/src/modules/todos/components/add-category.tsx b/src/modules/todos/components/add-category.tsx index 1c90cbd..5a95a5d 100644 --- a/src/modules/todos/components/add-category.tsx +++ b/src/modules/todos/components/add-category.tsx @@ -59,23 +59,18 @@ export function AddCategory({ onCategoryAdded }: AddCategoryProps) { const onSubmit = (data: AddCategoryFormData) => { startTransition(async () => { - try { - const newCategory = await createCategory(data); - onCategoryAdded(newCategory); + const result = await createCategory(data); + if (!result.success) { + toast.error(result.error || "Failed to create category"); + return; + } + + if (result.data) { + onCategoryAdded(result.data); form.reset(); setOpen(false); - toast.success("Category created successfully!"); - } catch (error) { - console.error("Error creating category:", error); - - const errorMessage = - error instanceof Error - ? error.message - : "Failed to create category"; - - toast.error(errorMessage); } }); }; From fa233f37a0a3be9b78beeed120d9014cbede245a Mon Sep 17 00:00:00 2001 From: Jez Date: Sat, 8 Nov 2025 15:28:38 +1100 Subject: [PATCH 11/28] docs: add comprehensive API endpoint documentation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add complete API reference documentation covering all REST endpoints and server actions. Contents: - REST API Endpoints (2 endpoints) - POST /api/summarize - Text summarization with Workers AI - /api/auth/[...all] - Better Auth endpoints - Server Actions (11 actions) - Authentication: signIn, signUp, signOut - Todos: getAllTodos, getTodoById, create, update, updateField, delete - Categories: getAllCategories, createCategory - Authentication Details - Better Auth configuration - OAuth providers (Google) - Utility functions - Data Models - Todo, Category, User, Session schemas - Validation rules and constraints - Error Handling - API error formats - Server action patterns - Security considerations Each endpoint/action includes: - Purpose and description - Request/response formats with TypeScript types - Authentication requirements - Error conditions and codes - Practical usage examples - Side effects (revalidation, redirects, R2 uploads) Total: 500+ lines of comprehensive API documentation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- docs/API_ENDPOINTS.md | 872 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 872 insertions(+) create mode 100644 docs/API_ENDPOINTS.md diff --git a/docs/API_ENDPOINTS.md b/docs/API_ENDPOINTS.md new file mode 100644 index 0000000..ccbf78e --- /dev/null +++ b/docs/API_ENDPOINTS.md @@ -0,0 +1,872 @@ +# API Documentation + +Complete API surface documentation for the Full-Stack Next.js Cloudflare Demo application. + +## Table of Contents + +1. [REST API Endpoints](#rest-api-endpoints) +2. [Server Actions](#server-actions) +3. [Authentication](#authentication) +4. [Data Models](#data-models) +5. [Error Handling](#error-handling) + +--- + +## REST API Endpoints + +### POST /api/summarize + +Summarize text using Cloudflare Workers AI. + +**Purpose**: Generate summaries of text content with configurable length, style, and language. + +**Authentication**: Required (session-based) + +**Request Body**: +```typescript +{ + text: string; // 50-50,000 characters + config?: { + maxLength?: number; // 50-1000, default: 200 + style?: "concise" | "detailed" | "bullet-points"; // default: "concise" + language?: string; // default: "English" + } +} +``` + +**Response** (200 OK): +```typescript +{ + success: true; + data: { + summary: string; + originalLength: number; + summaryLength: number; + tokensUsed: { + input: number; + output: number; + } + }; + error: null; +} +``` + +**Error Responses**: +- `401 Unauthorized`: User not authenticated + ```json + { "success": false, "error": "Authentication required", "data": null } + ``` +- `400 Bad Request`: Invalid input (via zod validation) +- `500 Internal Server Error`: AI service unavailable + ```json + { "success": false, "error": "AI service is not available", "data": null } + ``` + +**Example Usage**: +```typescript +const response = await fetch('/api/summarize', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + text: 'Long article text here...', + config: { maxLength: 150, style: 'bullet-points' } + }) +}); +const result = await response.json(); +``` + +--- + +### Better Auth Endpoints + +All Better Auth endpoints are handled via `/api/auth/[...all]`. + +**Base Path**: `/api/auth` + +**Supported Methods**: GET, POST + +**Available Routes** (handled by Better Auth): +- `POST /api/auth/sign-up/email` - Email/password sign up +- `POST /api/auth/sign-in/email` - Email/password sign in +- `POST /api/auth/sign-out` - Sign out current session +- `GET /api/auth/session` - Get current session +- `GET /api/auth/get-session` - Alternative session endpoint +- OAuth routes for Google sign-in (configured) + +**Configuration**: +- Email/Password authentication: Enabled +- Google OAuth: Enabled (requires GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET) +- Session storage: D1 database via Drizzle adapter +- Cookie handling: Next.js cookies plugin + +**Example - Get Session**: +```typescript +const response = await fetch('/api/auth/session'); +const session = await response.json(); +// Returns: { user: { id, name, email, ... }, session: { ... } } +``` + +--- + +## Server Actions + +All server actions use Next.js Server Actions (`"use server"`) and return structured responses. + +### Authentication Actions + +#### signIn() + +**Path**: `src/modules/auth/actions/auth.action.ts` + +**Purpose**: Authenticate user with email and password. + +**Parameters**: +```typescript +{ + email: string; // Valid email format + password: string; // Minimum 8 characters +} +``` + +**Returns**: +```typescript +{ + success: boolean; + message: string; // "Signed in successfully" or error message +} +``` + +**Authentication**: No (this creates the session) + +**Usage**: +```typescript +import { signIn } from '@/modules/auth/actions/auth.action'; + +const result = await signIn({ email: 'user@example.com', password: 'password123' }); +if (result.success) { + // Redirect to dashboard +} +``` + +--- + +#### signUp() + +**Path**: `src/modules/auth/actions/auth.action.ts` + +**Purpose**: Create new user account with email and password. + +**Parameters**: +```typescript +{ + email: string; // Valid email format + password: string; // Minimum 8 characters + username: string; // Minimum 3 characters +} +``` + +**Returns**: +```typescript +{ + success: boolean; + message: string; // "Signed up successfully" or error message +} +``` + +**Authentication**: No (creates new user) + +**Usage**: +```typescript +import { signUp } from '@/modules/auth/actions/auth.action'; + +const result = await signUp({ + email: 'user@example.com', + password: 'password123', + username: 'johndoe' +}); +``` + +--- + +#### signOut() + +**Path**: `src/modules/auth/actions/auth.action.ts` + +**Purpose**: End current user session. + +**Parameters**: None + +**Returns**: +```typescript +{ + success: boolean; + message: string; // "Signed out successfully" or error message +} +``` + +**Authentication**: Required (implicitly via session headers) + +**Usage**: +```typescript +import { signOut } from '@/modules/auth/actions/auth.action'; + +const result = await signOut(); +if (result.success) { + // Redirect to login +} +``` + +--- + +### Todo Actions + +#### getAllTodos() + +**Path**: `src/modules/todos/actions/get-todos.action.ts` + +**Purpose**: Fetch all todos for the authenticated user with category information. + +**Parameters**: None + +**Returns**: +```typescript +Todo[] // Array of todos (see Data Models section) +``` + +**Authentication**: Yes (via requireAuth()) + +**Database Query**: +- Joins todos with categories +- Filters by authenticated user ID +- Orders by creation date + +**Error Handling**: Returns empty array on error + +**Usage**: +```typescript +import getAllTodos from '@/modules/todos/actions/get-todos.action'; + +const todos = await getAllTodos(); +// Returns: [{ id, title, description, categoryName, ... }, ...] +``` + +--- + +#### getTodoById() + +**Path**: `src/modules/todos/actions/get-todo-by-id.action.ts` + +**Purpose**: Fetch a single todo by ID for the authenticated user. + +**Parameters**: +```typescript +id: number // Todo ID +``` + +**Returns**: +```typescript +Todo | null // Todo object or null if not found +``` + +**Authentication**: Yes (via requireAuth()) + +**Database Query**: +- Joins with categories +- Verifies todo belongs to authenticated user +- Returns null if not found or unauthorized + +**Usage**: +```typescript +import { getTodoById } from '@/modules/todos/actions/get-todo-by-id.action'; + +const todo = await getTodoById(123); +if (todo) { + // Display todo details +} +``` + +--- + +#### createTodoAction() + +**Path**: `src/modules/todos/actions/create-todo.action.ts` + +**Purpose**: Create a new todo with optional image upload to R2. + +**Parameters**: `FormData` object with the following fields: +```typescript +{ + title: string; // Required, 3-255 characters + description?: string; // Optional, max 1000 characters + categoryId?: number; // Optional category ID + status?: "pending" | "in_progress" | "completed" | "archived"; + priority?: "low" | "medium" | "high" | "urgent"; + completed?: boolean; // Default: false + dueDate?: string; // ISO date string + imageUrl?: string; // Optional image URL + imageAlt?: string; // Optional alt text + image?: File; // Optional image file for upload +} +``` + +**Returns**: Redirects to todo list on success + +**Authentication**: Yes (via requireAuth()) + +**Side Effects**: +- Uploads image to R2 if provided (bucket: "todo-images") +- Revalidates `/dashboard/todos` path +- Redirects to todo list after creation + +**Throws**: +- Zod validation errors for invalid data +- "Authentication required" error if not authenticated +- Generic error message for other failures + +**Usage**: +```typescript +import { createTodoAction } from '@/modules/todos/actions/create-todo.action'; + +const formData = new FormData(); +formData.append('title', 'New Task'); +formData.append('description', 'Task description'); +formData.append('priority', 'high'); +formData.append('image', fileInput.files[0]); + +await createTodoAction(formData); +// Automatically redirects on success +``` + +--- + +#### updateTodoAction() + +**Path**: `src/modules/todos/actions/update-todo.action.ts` + +**Purpose**: Update an existing todo with optional new image. + +**Parameters**: +```typescript +todoId: number // Todo ID to update +formData: FormData // Form fields (all optional, partial update) +``` + +**FormData Fields** (all optional): +```typescript +{ + title?: string; + description?: string; + categoryId?: number; + status?: "pending" | "in_progress" | "completed" | "archived"; + priority?: "low" | "medium" | "high" | "urgent"; + completed?: boolean; + dueDate?: string; + imageUrl?: string; + imageAlt?: string; + image?: File; // New image file +} +``` + +**Returns**: Redirects to todo list on success + +**Authentication**: Yes (via requireAuth()) + +**Database Query**: +- Verifies todo belongs to authenticated user +- Only updates provided fields +- Sets updatedAt timestamp + +**Side Effects**: +- Uploads new image to R2 if provided +- Revalidates `/dashboard/todos` path +- Redirects to todo list + +**Throws**: +- "Todo not found or unauthorized" if todo doesn't exist or belongs to another user +- Validation errors for invalid data + +**Usage**: +```typescript +import { updateTodoAction } from '@/modules/todos/actions/update-todo.action'; + +const formData = new FormData(); +formData.append('title', 'Updated Title'); +formData.append('status', 'completed'); + +await updateTodoAction(123, formData); +``` + +--- + +#### updateTodoFieldAction() + +**Path**: `src/modules/todos/actions/update-todo.action.ts` + +**Purpose**: Update specific fields of a todo (optimized for checkbox toggles). + +**Parameters**: +```typescript +{ + todoId: number; + data: { + completed?: boolean; // Currently only supports completed field + } +} +``` + +**Returns**: +```typescript +{ + success: boolean; + data?: Todo; // Updated todo object if successful + error?: string; // Error message if failed +} +``` + +**Authentication**: Yes (via requireAuth()) + +**Side Effects**: +- Auto-updates status to "completed" or "pending" based on completed field +- Revalidates `/dashboard/todos` path +- Does NOT redirect (returns data for optimistic UI updates) + +**Usage**: +```typescript +import { updateTodoFieldAction } from '@/modules/todos/actions/update-todo.action'; + +const result = await updateTodoFieldAction(123, { completed: true }); +if (result.success) { + // Update UI optimistically +} +``` + +--- + +#### deleteTodoAction() + +**Path**: `src/modules/todos/actions/delete-todo.action.ts` + +**Purpose**: Delete a todo by ID. + +**Parameters**: +```typescript +todoId: number // Todo ID to delete +``` + +**Returns**: +```typescript +{ + success: boolean; + message?: string; // Success message + error?: string; // Error message if failed +} +``` + +**Authentication**: Yes (via requireAuth()) + +**Database Query**: +- Verifies todo exists and belongs to authenticated user +- Deletes todo record + +**Side Effects**: +- Revalidates `/dashboard/todos` path + +**Usage**: +```typescript +import { deleteTodoAction } from '@/modules/todos/actions/delete-todo.action'; + +const result = await deleteTodoAction(123); +if (result.success) { + // Show success message +} +``` + +--- + +### Category Actions + +#### getAllCategories() + +**Path**: `src/modules/todos/actions/get-categories.action.ts` + +**Purpose**: Fetch all categories for a specific user. + +**Parameters**: +```typescript +userId: string // User ID +``` + +**Returns**: +```typescript +Category[] // Array of categories (see Data Models section) +``` + +**Authentication**: No (but requires userId parameter) + +**Database Query**: +- Filters categories by userId +- Orders by creation date + +**Error Handling**: Returns empty array on error + +**Usage**: +```typescript +import { getAllCategories } from '@/modules/todos/actions/get-categories.action'; + +const categories = await getAllCategories(user.id); +``` + +--- + +#### createCategory() + +**Path**: `src/modules/todos/actions/create-category.action.ts` + +**Purpose**: Create a new category for the authenticated user. + +**Parameters**: +```typescript +{ + name: string; // Required + color?: string; // Optional, default: "#6366f1" + description?: string; // Optional +} +``` + +**Returns**: +```typescript +Category // Created category object +``` + +**Authentication**: Yes (via requireAuth()) + +**Side Effects**: +- Revalidates `/dashboard/todos` and `/dashboard/todos/new` paths + +**Throws**: +- Zod validation errors for invalid data +- "Failed to create category" if database operation fails + +**Usage**: +```typescript +import { createCategory } from '@/modules/todos/actions/create-category.action'; + +const category = await createCategory({ + name: 'Work', + color: '#ff6347', + description: 'Work-related tasks' +}); +``` + +--- + +## Authentication + +### Better Auth Configuration + +**Provider**: Better Auth library with Drizzle adapter +**Database**: Cloudflare D1 (SQLite) +**Session Storage**: Database-backed sessions + +**Authentication Methods**: +1. Email/Password (enabled) +2. Google OAuth (enabled) + +**Environment Variables Required**: +- `BETTER_AUTH_SECRET` - Secret key for signing tokens +- `GOOGLE_CLIENT_ID` - Google OAuth client ID +- `GOOGLE_CLIENT_SECRET` - Google OAuth client secret + +**Session Management**: +- Sessions stored in database with expiration +- Cookies managed by Better Auth Next.js plugin +- Automatic session refresh + +### Auth Utility Functions + +All utilities in `src/modules/auth/utils/auth-utils.ts`: + +#### getCurrentUser() + +Returns the current authenticated user or null. + +```typescript +const user = await getCurrentUser(); +// Returns: { id: string, name: string, email: string } | null +``` + +#### requireAuth() + +Returns the current user or throws "Authentication required" error. + +```typescript +const user = await requireAuth(); +// Throws if not authenticated +``` + +#### isAuthenticated() + +Check if user has valid session. + +```typescript +const authenticated = await isAuthenticated(); +// Returns: boolean +``` + +#### getSession() + +Get full session object including user and session metadata. + +```typescript +const session = await getSession(); +// Returns session object or null +``` + +--- + +## Data Models + +### Todo + +```typescript +{ + id: number; + title: string; + description: string | null; + categoryId: number | null; + categoryName: string | null; // Joined from categories table + userId: string; + status: "pending" | "in_progress" | "completed" | "archived"; + priority: "low" | "medium" | "high" | "urgent"; + imageUrl: string | null; + imageAlt: string | null; + completed: boolean; + dueDate: string | null; // ISO date string + createdAt: string; // ISO date string + updatedAt: string; // ISO date string +} +``` + +**Validation Rules**: +- `title`: 3-255 characters +- `description`: max 1000 characters +- `imageUrl`: valid URL or empty string +- `status`: defaults to "pending" +- `priority`: defaults to "medium" +- `completed`: defaults to false + +--- + +### Category + +```typescript +{ + id: number; + name: string; + color: string; // Hex color, default: "#6366f1" + description: string | null; + userId: string; + createdAt: string; // ISO date string + updatedAt: string; // ISO date string +} +``` + +**Validation Rules**: +- `name`: required, minimum 1 character +- `color`: optional, defaults to indigo +- `userId`: automatically set from authenticated user + +--- + +### User (Auth) + +```typescript +{ + id: string; + name: string; + email: string; + emailVerified: boolean; + image: string | null; + createdAt: Date; + updatedAt: Date; +} +``` + +**Public Interface** (AuthUser): +```typescript +{ + id: string; + name: string; + email: string; +} +``` + +--- + +### Session + +```typescript +{ + id: string; + expiresAt: Date; + token: string; + createdAt: Date; + updatedAt: Date; + ipAddress: string | null; + userAgent: string | null; + userId: string; +} +``` + +--- + +## Error Handling + +### API Endpoint Errors + +REST endpoints return standardized error responses: + +```typescript +{ + success: false; + error: string; // Human-readable error message + data: null; +} +``` + +**Common HTTP Status Codes**: +- `400` - Bad Request (validation errors) +- `401` - Unauthorized (authentication required) +- `404` - Not Found +- `500` - Internal Server Error + +### Server Action Errors + +**Pattern 1: Return Object** (for UI handling) +```typescript +{ + success: false; + error: string; +} +``` + +**Pattern 2: Throw Error** (for form actions with redirects) +```typescript +throw new Error("Specific error message"); +``` + +**Pattern 3: Redirect on Success** (form actions) +- Uses Next.js `redirect()` after successful mutation +- Throws `NEXT_REDIRECT` error (normal behavior) +- Revalidates paths before redirect + +### Authentication Errors + +All protected actions check authentication: + +```typescript +const user = await requireAuth(); +// Throws "Authentication required" if not authenticated +``` + +Caller should handle: +```typescript +try { + await protectedAction(); +} catch (error) { + if (error.message === "Authentication required") { + // Redirect to login + } +} +``` + +--- + +## Security Considerations + +1. **Authorization**: All todo/category operations verify userId matches authenticated user +2. **File Uploads**: Images validated and uploaded to R2 with scoped paths +3. **SQL Injection**: Prevented by Drizzle ORM parameterized queries +4. **XSS**: React escapes output by default +5. **CSRF**: Better Auth handles CSRF tokens automatically +6. **Rate Limiting**: Not implemented (consider adding for production) + +--- + +## Development Notes + +### Revalidation Strategy + +Actions that modify data revalidate affected paths: + +```typescript +revalidatePath('/dashboard/todos'); // List page +revalidatePath('/dashboard/todos/new'); // New todo page +``` + +### Redirect Pattern + +Form actions redirect after success: + +```typescript +revalidatePath(todosRoutes.list); +redirect(todosRoutes.list); // Throws NEXT_REDIRECT +``` + +### Image Upload Flow + +1. Check if FormData contains image file +2. Upload to R2 bucket ("todo-images") +3. Store returned URL in database +4. Generate alt text from filename if not provided +5. Log errors but don't fail todo creation + +### Database Adapter + +- Uses Drizzle ORM with D1 adapter +- Better Auth uses Drizzle adapter for session storage +- All queries are type-safe with TypeScript + +--- + +## Quick Reference + +### Most Common Operations + +**Create Todo**: +```typescript +const formData = new FormData(); +formData.append('title', 'Task'); +await createTodoAction(formData); +``` + +**Update Todo Checkbox**: +```typescript +await updateTodoFieldAction(todoId, { completed: true }); +``` + +**Delete Todo**: +```typescript +await deleteTodoAction(todoId); +``` + +**Get All Todos**: +```typescript +const todos = await getAllTodos(); +``` + +**Sign In**: +```typescript +const result = await signIn({ email, password }); +``` + +**Check Authentication**: +```typescript +const user = await getCurrentUser(); +if (!user) redirect('/login'); +``` + +--- + +**Last Updated**: 2025-11-08 +**API Version**: 1.0.0 From f355e686951f78dfccdb3d0e2cb923da14960f39 Mon Sep 17 00:00:00 2001 From: Jez Date: Sat, 8 Nov 2025 15:39:59 +1100 Subject: [PATCH 12/28] fix: replace alert() with toast in toggle-complete component - Import react-hot-toast for consistent error feedback - Replace alert() with toast.error() on line 35 - Simplify error message to match other components - Consistent with PR #14 (delete-todo.tsx) Provides professional, non-blocking error notifications when toggling todo completion fails. --- src/modules/todos/components/toggle-complete.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/modules/todos/components/toggle-complete.tsx b/src/modules/todos/components/toggle-complete.tsx index a652ec2..5b62cb5 100644 --- a/src/modules/todos/components/toggle-complete.tsx +++ b/src/modules/todos/components/toggle-complete.tsx @@ -1,6 +1,7 @@ "use client"; import { useState, useTransition } from "react"; +import toast from "react-hot-toast"; import { Checkbox } from "@/components/ui/checkbox"; import { updateTodoFieldAction } from "../actions/update-todo.action"; @@ -31,8 +32,8 @@ export function ToggleComplete({ todoId, completed }: ToggleCompleteProps) { console.error("Error updating todo:", error); // Revert the optimistic update setIsCompleted(!checked); - alert( - `Error updating todo: ${error instanceof Error ? error.message : "Unknown error"}`, + toast.error( + error instanceof Error ? error.message : "Failed to update todo", ); } }); From 1f0d87ed516c3e93ea22f7fced3d5055cada317d Mon Sep 17 00:00:00 2001 From: Jez Date: Sat, 8 Nov 2025 15:41:25 +1100 Subject: [PATCH 13/28] feat: add success feedback for todo create/edit operations - Import react-hot-toast and useRouter - Use toast.promise to show loading/success/error states - Show 'Creating/Updating todo...' during operation - Show success message before redirect - Provides clear user feedback for save operations Enhances UX by confirming successful todo creation/updates before redirect happens. --- src/modules/todos/components/todo-form.tsx | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/src/modules/todos/components/todo-form.tsx b/src/modules/todos/components/todo-form.tsx index d26d514..34f3680 100644 --- a/src/modules/todos/components/todo-form.tsx +++ b/src/modules/todos/components/todo-form.tsx @@ -3,8 +3,10 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { Upload, X } from "lucide-react"; import Image from "next/image"; +import { useRouter } from "next/navigation"; import { useState, useTransition } from "react"; import { useForm } from "react-hook-form"; +import toast from "react-hot-toast"; import type { z } from "zod"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; @@ -72,6 +74,7 @@ export function TodoForm({ categories: initialCategories, initialData, }: TodoFormProps) { + const router = useRouter(); const [isPending, startTransition] = useTransition(); const [imageFile, setImageFile] = useState(null); const [imagePreview, setImagePreview] = useState( @@ -162,12 +165,21 @@ export function TodoForm({ formData.append("image", imageFile); } - if (initialData) { - await updateTodoAction(initialData.id, formData); - } else { - await createTodoAction(formData); - } + // Show success feedback before redirect + const actionPromise = initialData + ? updateTodoAction(initialData.id, formData) + : createTodoAction(formData); + + await toast.promise(actionPromise, { + loading: initialData ? "Updating todo..." : "Creating todo...", + success: initialData + ? "Todo updated successfully!" + : "Todo created successfully!", + error: (err) => + err instanceof Error ? err.message : "Failed to save todo", + }); } catch (error) { + // Error already shown by toast.promise console.error("Error saving todo:", error); } }); From 821c4ac60cd390f8440e44ea10f38208a1b9fa13 Mon Sep 17 00:00:00 2001 From: Jez Date: Sat, 8 Nov 2025 15:44:24 +1100 Subject: [PATCH 14/28] feat: add warnings when image upload fails during todo operations - Import cookies() in create-todo and update-todo actions - Set 'todo-warning' cookie when R2 upload fails - Create WarningToast component to display cookie-based warnings - Integrate WarningToast into todo-list page - Clear warning cookie after displaying Now users see: 'Image upload failed, but todo was created/updated successfully' when R2 upload fails but todo operation succeeds. Prevents silent failures. --- SESSION.md | 163 ++++ docs/DATABASE_SCHEMA.md | 755 ++++++++++++++++ docs/IMPLEMENTATION_PHASES.md | 836 ++++++++++++++++++ .../todos/actions/create-todo.action.ts | 7 + .../todos/actions/update-todo.action.ts | 7 + .../todos/components/warning-toast.tsx | 21 + src/modules/todos/todo-list.page.tsx | 13 + 7 files changed, 1802 insertions(+) create mode 100644 SESSION.md create mode 100644 docs/DATABASE_SCHEMA.md create mode 100644 docs/IMPLEMENTATION_PHASES.md create mode 100644 src/modules/todos/components/warning-toast.tsx 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

From 2f3811d7c102e0d4c4f6973c16167db6582cf8f7 Mon Sep 17 00:00:00 2001 From: Jez Date: Sat, 8 Nov 2025 15:47:40 +1100 Subject: [PATCH 15/28] feat: add loading indicator for image uploads in todo form - Add spinning loader icon to submit button during submission - Show specific text when image is being uploaded: - 'Creating with image...' for new todos with images - 'Updating with image...' for editing todos with images - Generic 'Creating...' / 'Updating...' when no image - Disable cancel button during submission - Visual feedback improves UX during potentially slow image uploads Users now see clear indication that image upload is in progress. --- src/modules/todos/components/todo-form.tsx | 43 ++++++++++++++++++---- 1 file changed, 36 insertions(+), 7 deletions(-) diff --git a/src/modules/todos/components/todo-form.tsx b/src/modules/todos/components/todo-form.tsx index d26d514..e0a4c8a 100644 --- a/src/modules/todos/components/todo-form.tsx +++ b/src/modules/todos/components/todo-form.tsx @@ -499,17 +499,46 @@ export function TodoForm({ type="button" variant="outline" onClick={() => window.history.back()} + disabled={isPending} > Cancel
From ecb46992d77e788c2119831a288ba1c195906b8d Mon Sep 17 00:00:00 2001 From: Jez Date: Sat, 8 Nov 2025 15:53:34 +1100 Subject: [PATCH 16/28] refactor: replace hard-coded colors with semantic theme colors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Changes: - todo-card.tsx: - text-gray-500/400/600 → text-muted-foreground (with opacity variants) - text-blue-600 → text-primary (image badge) - text-red-600 → text-destructive (overdue indicators) - todo-list.page.tsx: - text-gray-600/500/400 → text-muted-foreground (with opacity variants) Benefits: - Theme-aware: Colors adapt to light/dark mode automatically - Consistent: Uses shadcn/ui semantic color system - Maintainable: Change theme by editing CSS variables - Professional: Avoids arbitrary hard-coded colors Note: Priority/status badge colors (green/yellow/red/etc) kept as-is since they're functional semantic indicators, not UI colors. --- src/modules/todos/components/todo-card.tsx | 10 +++++----- src/modules/todos/todo-list.page.tsx | 8 ++++---- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/modules/todos/components/todo-card.tsx b/src/modules/todos/components/todo-card.tsx index 561ba94..db45442 100644 --- a/src/modules/todos/components/todo-card.tsx +++ b/src/modules/todos/components/todo-card.tsx @@ -68,13 +68,13 @@ export function TodoCard({ todo }: TodoCardProps) {

{todo.title}

{todo.description && (

{todo.description}

@@ -120,7 +120,7 @@ export function TodoCard({ todo }: TodoCardProps) { {todo.categoryName} )} {todo.imageUrl && ( - + Image @@ -129,12 +129,12 @@ export function TodoCard({ todo }: TodoCardProps) { {todo.dueDate && (
Due: {formatDate(todo.dueDate)} {isOverdue && ( - + (Overdue) )} diff --git a/src/modules/todos/todo-list.page.tsx b/src/modules/todos/todo-list.page.tsx index 0939b82..e852a33 100644 --- a/src/modules/todos/todo-list.page.tsx +++ b/src/modules/todos/todo-list.page.tsx @@ -13,7 +13,7 @@ export default async function TodoListPage() {

Todos

-

+

Manage your tasks and stay organized

@@ -27,11 +27,11 @@ export default async function TodoListPage() { {todos.length === 0 ? (
-
📝
-

+
📝
+

No todos yet

-

+

Create your first todo to get started

From 12b8e2b4b56338512d4f25bb0e3f4f29b4970955 Mon Sep 17 00:00:00 2001 From: Jez Date: Sat, 8 Nov 2025 18:41:37 +1100 Subject: [PATCH 17/28] feat: implement dark mode with complete semantic color system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Install next-themes package - Create ThemeProvider component with localStorage persistence - Create ModeToggle cycling button (Light → Dark → System) - Update layout.tsx: - Wrap app with ThemeProvider - Add suppressHydrationWarning to html tag - Replace bg-gray-50 with bg-background - Update navigation.tsx: - Add ModeToggle button next to LogoutButton - Replace bg-white with bg-card - Remove hard-coded text colors **Complete semantic color audit (54 violations fixed):** - dashboard.page.tsx: Replace all gray/blue/green/purple with semantic - todo-card.tsx: text-muted-foreground, text-primary, text-destructive - todo-list.page.tsx: text-muted-foreground variants - delete-todo.tsx: text/bg-destructive for delete actions - todo-form.tsx: border-border, text-primary for upload UI - edit-todo.page.tsx: text-muted-foreground - new-todo.page.tsx: text-muted-foreground Priority/status badge colors kept as-is (semantic data visualization). Dark mode now fully functional with proper theme-aware colors. --- package.json | 1 + pnpm-lock.yaml | 14 +++++++ src/app/layout.tsx | 16 +++++-- src/components/mode-toggle.tsx | 44 ++++++++++++++++++++ src/components/navigation.tsx | 10 +++-- src/components/theme-provider.tsx | 8 ++++ src/modules/dashboard/dashboard.page.tsx | 24 +++++------ src/modules/todos/components/delete-todo.tsx | 4 +- src/modules/todos/components/todo-card.tsx | 10 ++--- src/modules/todos/components/todo-form.tsx | 8 ++-- src/modules/todos/edit-todo.page.tsx | 2 +- src/modules/todos/new-todo.page.tsx | 2 +- src/modules/todos/todo-list.page.tsx | 8 ++-- 13 files changed, 115 insertions(+), 36 deletions(-) create mode 100644 src/components/mode-toggle.tsx create mode 100644 src/components/theme-provider.tsx diff --git a/package.json b/package.json index 7c5f3e8..5f76957 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "drizzle-zod": "^0.8.3", "lucide-react": "^0.544.0", "next": "15.4.6", + "next-themes": "^0.4.6", "react": "19.1.0", "react-dom": "19.1.0", "react-hook-form": "^7.62.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 11a3696..b14f415 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -62,6 +62,9 @@ importers: next: specifier: 15.4.6 version: 15.4.6(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + next-themes: + specifier: ^0.4.6 + version: 0.4.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0) react: specifier: 19.1.0 version: 19.1.0 @@ -3220,6 +3223,12 @@ packages: resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} engines: {node: '>= 0.6'} + next-themes@0.4.6: + resolution: {integrity: sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==} + peerDependencies: + react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc + react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc + next@15.4.6: resolution: {integrity: sha512-us++E/Q80/8+UekzB3SAGs71AlLDsadpFMXVNM/uQ0BMwsh9m3mr0UNQIfjKed8vpWXsASe+Qifrnu1oLIcKEQ==} engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0} @@ -7423,6 +7432,11 @@ snapshots: negotiator@1.0.0: {} + next-themes@0.4.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0): + dependencies: + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + next@15.4.6(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0): dependencies: '@next/env': 15.4.6 diff --git a/src/app/layout.tsx b/src/app/layout.tsx index c4406ed..6ae61e4 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -2,6 +2,7 @@ import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; import "./globals.css"; import { Toaster } from "react-hot-toast"; +import { ThemeProvider } from "@/components/theme-provider"; const geistSans = Geist({ variable: "--font-geist-sans", @@ -27,12 +28,19 @@ export default function RootLayout({ children: React.ReactNode; }>) { return ( - + -
{children}
- + +
{children}
+ +
); diff --git a/src/components/mode-toggle.tsx b/src/components/mode-toggle.tsx new file mode 100644 index 0000000..eb4d819 --- /dev/null +++ b/src/components/mode-toggle.tsx @@ -0,0 +1,44 @@ +"use client"; + +import { Moon, Sun, Monitor } from "lucide-react"; +import { useTheme } from "next-themes"; +import { useEffect, useState } from "react"; +import { Button } from "@/components/ui/button"; + +export function ModeToggle() { + const { theme, setTheme } = useTheme(); + const [mounted, setMounted] = useState(false); + + // Avoid hydration mismatch by only rendering after mount + useEffect(() => { + setMounted(true); + }, []); + + if (!mounted) { + return ( + + ); + } + + const cycleTheme = () => { + if (theme === "light") { + setTheme("dark"); + } else if (theme === "dark") { + setTheme("system"); + } else { + setTheme("light"); + } + }; + + return ( + + ); +} diff --git a/src/components/navigation.tsx b/src/components/navigation.tsx index a6a3b0f..04535c3 100644 --- a/src/components/navigation.tsx +++ b/src/components/navigation.tsx @@ -2,16 +2,17 @@ import { CheckSquare, Home } from "lucide-react"; import Link from "next/link"; import { Button } from "@/components/ui/button"; import LogoutButton from "../modules/auth/components/logout-button"; +import { ModeToggle } from "@/components/mode-toggle"; export function Navigation() { return ( -
diff --git a/src/components/theme-provider.tsx b/src/components/theme-provider.tsx new file mode 100644 index 0000000..fba8ad6 --- /dev/null +++ b/src/components/theme-provider.tsx @@ -0,0 +1,8 @@ +"use client"; + +import { ThemeProvider as NextThemesProvider } from "next-themes"; +import { type ThemeProviderProps } from "next-themes"; + +export function ThemeProvider({ children, ...props }: ThemeProviderProps) { + return {children}; +} diff --git a/src/modules/dashboard/dashboard.page.tsx b/src/modules/dashboard/dashboard.page.tsx index 204e4da..5e79f3b 100644 --- a/src/modules/dashboard/dashboard.page.tsx +++ b/src/modules/dashboard/dashboard.page.tsx @@ -13,10 +13,10 @@ export default async function Dashboard() { return (
-

+

Welcome to TodoApp

-

+

A simple and elegant todo application built with Next.js 15, TailwindCSS, and shadcn/ui components.

@@ -65,34 +65,34 @@ export default async function Dashboard() {
-

+

Features

-
- +
+

Task Management

-

+

Create, edit, and delete todos with ease

-
- +
+

Categories

-

+

Organize your todos with custom categories

-
- +
+

Rich Features

-

+

Priorities, due dates, images, and more

diff --git a/src/modules/todos/components/delete-todo.tsx b/src/modules/todos/components/delete-todo.tsx index 933ebca..910f0f0 100644 --- a/src/modules/todos/components/delete-todo.tsx +++ b/src/modules/todos/components/delete-todo.tsx @@ -50,7 +50,7 @@ export function DeleteTodo({ todoId }: DeleteTodoProps) { @@ -68,7 +68,7 @@ export function DeleteTodo({ todoId }: DeleteTodoProps) { {isPending ? "Deleting..." : "Delete"} diff --git a/src/modules/todos/components/todo-card.tsx b/src/modules/todos/components/todo-card.tsx index 561ba94..db45442 100644 --- a/src/modules/todos/components/todo-card.tsx +++ b/src/modules/todos/components/todo-card.tsx @@ -68,13 +68,13 @@ export function TodoCard({ todo }: TodoCardProps) {

{todo.title}

{todo.description && (

{todo.description}

@@ -120,7 +120,7 @@ export function TodoCard({ todo }: TodoCardProps) { {todo.categoryName} )} {todo.imageUrl && ( - + Image @@ -129,12 +129,12 @@ export function TodoCard({ todo }: TodoCardProps) { {todo.dueDate && (
Due: {formatDate(todo.dueDate)} {isOverdue && ( - + (Overdue) )} diff --git a/src/modules/todos/components/todo-form.tsx b/src/modules/todos/components/todo-form.tsx index d26d514..81e8eae 100644 --- a/src/modules/todos/components/todo-form.tsx +++ b/src/modules/todos/components/todo-form.tsx @@ -443,12 +443,12 @@ export function TodoForm({
) : ( -
- +
+
@@ -460,7 +460,7 @@ export function TodoForm({ onChange={handleImageChange} />
-

+

PNG, JPG up to 5MB

diff --git a/src/modules/todos/edit-todo.page.tsx b/src/modules/todos/edit-todo.page.tsx index 9b63f8d..c3644ed 100644 --- a/src/modules/todos/edit-todo.page.tsx +++ b/src/modules/todos/edit-todo.page.tsx @@ -39,7 +39,7 @@ export default async function EditTodoPage({ id }: EditTodoPageProps) {

Edit Todo

-

Update your task details

+

Update your task details

diff --git a/src/modules/todos/new-todo.page.tsx b/src/modules/todos/new-todo.page.tsx index 2f2c87e..ff8d3ec 100644 --- a/src/modules/todos/new-todo.page.tsx +++ b/src/modules/todos/new-todo.page.tsx @@ -20,7 +20,7 @@ export default async function NewTodoPage() {

Create New Todo

-

+

Add a new task to your todo list

diff --git a/src/modules/todos/todo-list.page.tsx b/src/modules/todos/todo-list.page.tsx index 0939b82..e852a33 100644 --- a/src/modules/todos/todo-list.page.tsx +++ b/src/modules/todos/todo-list.page.tsx @@ -13,7 +13,7 @@ export default async function TodoListPage() {

Todos

-

+

Manage your tasks and stay organized

@@ -27,11 +27,11 @@ export default async function TodoListPage() { {todos.length === 0 ? (
-
📝
-

+
📝
+

No todos yet

-

+

Create your first todo to get started

From 9f73848281c66caa04de15d9b824aaa753800431 Mon Sep 17 00:00:00 2001 From: Jez Date: Sat, 8 Nov 2025 19:16:28 +1100 Subject: [PATCH 18/28] refactor: use route constants and semantic colors in navigation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Import dashboardRoutes and todosRoutes for type-safe navigation - Replace hardcoded paths with route constants (dashboardRoutes.dashboard, todosRoutes.list) - Fix dark mode colors: bg-white → bg-card, remove text-gray-900 - Improves maintainability and dark mode support --- src/components/navigation.tsx | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/components/navigation.tsx b/src/components/navigation.tsx index 9595f27..fb71dbf 100644 --- a/src/components/navigation.tsx +++ b/src/components/navigation.tsx @@ -2,27 +2,29 @@ import { CheckSquare, Home } from "lucide-react"; import Link from "next/link"; import { Button } from "@/components/ui/button"; import LogoutButton from "../modules/auth/components/logout-button"; +import dashboardRoutes from "@/modules/dashboard/dashboard.route"; +import todosRoutes from "@/modules/todos/todos.route"; export function Navigation() { return ( -