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/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..fbc6e6f --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,662 @@ +# Claude Code Project Context + +**Project:** fullstack-next-cloudflare-demo +**Type:** Modular starter kit / template +**Last Updated:** 2025-11-08 + +--- + +## Project Overview + +### Tech Stack + +**Frontend:** +- Next.js 15.4.6 (App Router, React Server Components) +- React 19.1.0 +- TailwindCSS v4 +- shadcn/ui (Radix UI components) +- React Hook Form + Zod validation + +**Backend & Infrastructure:** +- Cloudflare Workers (via @opennextjs/cloudflare 1.11.1) +- Cloudflare D1 (SQLite at the edge) +- Cloudflare R2 (object storage) +- Cloudflare Workers AI (optional) +- better-auth 1.3.9 (authentication) +- Drizzle ORM (database toolkit) + +**DevOps:** +- Wrangler 4.46.0 (Cloudflare CLI) +- pnpm (package manager) +- GitHub Actions (CI/CD - if configured) + +### Project Status + +This is a **production-ready modular starter kit** that can be forked and customized for new applications. + +**Current features:** +- ✅ Authentication (better-auth with Google OAuth) +- ✅ Database (D1 with Drizzle ORM) +- ✅ Example CRUD module (todos with categories) +- ✅ Dark/light mode theming +- ✅ Modular architecture (see MODULES.md) + +**Intentionally missing:** +- ❌ Automated tests (manual testing only) +- ❌ Payment integration +- ❌ Email sending +- ❌ Advanced AI features (example exists, not production-ready) + +--- + +## Development Workflow + +### Starting Development + +**Two-terminal setup (recommended):** + +```bash +# Terminal 1: Start Wrangler (provides D1 database access) +pnpm run wrangler:dev + +# Terminal 2: Start Next.js (provides hot module reload) +pnpm run dev +``` + +**Access points:** +- **Next.js app:** http://localhost:3000 (use this for development) +- **Wrangler dev:** http://localhost:8787 (Cloudflare runtime, no HMR) + +**Alternative (single terminal, no HMR):** +```bash +pnpm run dev:cf +``` + +### Why Two Terminals? + +- **Wrangler** provides local D1 database access and Cloudflare bindings +- **Next.js dev** provides fast refresh and better DX +- They run on different ports but share the local D1 instance + +### Environment Setup + +**Required files:** +- `.dev.vars` - Local development secrets (gitignored) +- `wrangler.jsonc` - Cloudflare configuration + +**Key environment variables:** +```bash +# .dev.vars +CLOUDFLARE_ACCOUNT_ID=your-account-id +BETTER_AUTH_SECRET=your-random-secret +GOOGLE_CLIENT_ID=your-google-client-id +GOOGLE_CLIENT_SECRET=your-google-client-secret +CLOUDFLARE_R2_URL=https://pub-xxxxx.r2.dev +``` + +Generate auth secret: +```bash +openssl rand -base64 32 +``` + +--- + +## Build & Deploy + +### Local Build + +```bash +# Build for Cloudflare Workers +pnpm run build:cf + +# This runs: @opennextjs/cloudflare build +# Output: .worker-next/ directory +``` + +**Verify build succeeded:** +1. Check for `.worker-next/` directory +2. No TypeScript errors in output +3. No build warnings about missing bindings + +### Deployment + +**Preview deployment (test before production):** +```bash +pnpm run deploy:preview + +# Uses wrangler.jsonc [env.preview] config +``` + +**Production deployment:** +```bash +pnpm run deploy + +# Uses wrangler.jsonc main config +``` + +### Post-Deployment Checks + +1. **Visit deployed URL** - Check app loads +2. **Test authentication** - Login with Google OAuth +3. **Test CRUD operations** - Create/edit/delete a todo +4. **Check database** - Verify data persists +5. **Check categories** - Verify category colors work + +**Common deployment issues:** +- Missing environment variables (check Wrangler secrets) +- Database migrations not applied (run `db:migrate:prod`) +- OAuth redirect URI mismatch (update Google OAuth settings) + +--- + +## Testing Strategy + +**Current approach:** Manual testing (no automated test suite) + +### Manual Testing Checklist + +**Basic smoke test (5 minutes):** +- [ ] App loads at http://localhost:3000 +- [ ] No console errors +- [ ] Dark/light theme toggle works +- [ ] Login redirects to Google OAuth +- [ ] After login, redirects to dashboard +- [ ] Can create a new todo +- [ ] Can edit a todo +- [ ] Can delete a todo (with confirmation dialog) +- [ ] Can create a category +- [ ] Category color picker works +- [ ] Can assign category to todo +- [ ] Category color displays on todo card +- [ ] Logout works + +**Database verification:** +```bash +# Open Drizzle Studio +pnpm run db:studio:local + +# Check tables exist: +# - user +# - session +# - account +# - verification +# - todos +# - categories + +# Verify data: +# - User record exists after login +# - Todos show correct userId +# - Categories show correct userId and color +``` + +**Build verification:** +```bash +# 1. Clean build +rm -rf .next .worker-next + +# 2. Build for Cloudflare +pnpm run build:cf + +# 3. Check for errors +# - No TypeScript errors +# - No "module not found" errors +# - No warnings about missing bindings + +# 4. Check output +ls -la .worker-next/ +# Should contain worker bundle +``` + +### Testing New Features + +When adding a new feature module (see MODULE_TEMPLATE.md): + +1. **Create feature** (e.g., invoices module) +2. **Start dev servers** (wrangler + next) +3. **Test CRUD operations:** + - Create item + - View list + - Edit item + - Delete item +4. **Verify database:** + - Check Drizzle Studio + - Confirm user isolation (create second user, verify they only see their data) +5. **Test error handling:** + - Submit invalid form data + - Verify Zod validation works + - Check error messages display +6. **Build and deploy to preview:** + - `pnpm run deploy:preview` + - Test on live preview URL + +--- + +## Database Workflow + +### Schema Structure + +**Location:** `src/db/schema.ts` + +Schemas are defined in modules but exported centrally: + +```typescript +// Module schema: src/modules/todos/schemas/todo.schema.ts +export const todos = sqliteTable("todos", { ... }); + +// Central export: src/db/schema.ts +export { todos } from "@/modules/todos/schemas/todo.schema"; +``` + +**Why?** Allows Drizzle to generate migrations from all schemas while keeping modules self-contained. + +### Creating Migrations + +**When to create a migration:** +- Adding a new module with database tables +- Modifying existing table schema +- Adding/removing columns +- Changing column types + +**Steps:** + +```bash +# 1. Modify schema file in src/modules/[module]/schemas/ + +# 2. Generate migration with descriptive name +pnpm run db:generate:named "add_invoices_table" + +# 3. Review generated migration in src/drizzle/ +# - Check SQL is correct +# - Verify no unintended changes + +# 4. Apply to local database +pnpm run db:migrate:local + +# 5. Verify in Drizzle Studio +pnpm run db:studio:local + +# 6. Test your feature with the new schema + +# 7. Commit migration file +git add src/drizzle/XXXX_add_invoices_table.sql +git commit -m "feat: add invoices table migration" +``` + +### Migration Commands + +```bash +# Local (development) +pnpm run db:migrate:local + +# Preview (test environment) +pnpm run db:migrate:preview + +# Production (live) +pnpm run db:migrate:prod + +# Inspect tables +pnpm run db:inspect:local +pnpm run db:inspect:preview +pnpm run db:inspect:prod + +# Open Drizzle Studio +pnpm run db:studio:local +pnpm run db:studio # For remote DB +``` + +### Database Reset (Local Only) + +**Warning:** This deletes all local data! + +```bash +# Reset local database (drops all tables and reapplies migrations) +pnpm run db:reset:local + +# Or manually: +rm -rf .wrangler/state/v3/d1/miniflare-D1DatabaseObject/*.sqlite* +pnpm run db:migrate:local +``` + +--- + +## Architecture Decisions + +### Why Modular Architecture? + +**Decision:** Feature-based modules (`src/modules/auth`, `src/modules/todos`, etc.) + +**Reasons:** +- ✅ Easy to remove unwanted features when forking +- ✅ Clear separation of concerns +- ✅ Self-contained and reusable +- ✅ Industry standard (T3 Stack, Next.js Boilerplate use same pattern) +- ✅ No plugin system overhead (50-100+ hours to build) + +**See:** MODULES.md for complete guide + +### Why better-auth? + +**Alternatives considered:** Clerk, Auth.js, custom JWT + +**Chosen:** better-auth 1.3.9 + +**Reasons:** +- ✅ TypeScript-first with excellent DX +- ✅ Works with D1 (many auth libs don't) +- ✅ Lightweight (no external services required) +- ✅ Session management built-in +- ✅ Social auth (Google OAuth) easy to configure +- ❌ **Blocker:** No Next.js 16 support yet (tracking issue #5263) + +### Why Cloudflare Workers? + +**Alternatives considered:** Vercel, Netlify, traditional servers + +**Chosen:** Cloudflare Workers + @opennextjs/cloudflare + +**Reasons:** +- ✅ Edge deployment (300+ locations globally) +- ✅ Generous free tier (perfect for MVPs) +- ✅ D1 database included (SQLite at edge) +- ✅ R2 storage (S3-compatible, no egress fees) +- ✅ Workers AI (on-demand ML inference) +- ✅ Scales automatically +- ✅ No cold starts (unlike Lambda) + +**Trade-offs:** +- ❌ Not all npm packages work (no native modules) +- ❌ 10ms CPU limit (but restarts for new requests) +- ❌ Learning curve for Workers-specific patterns + +### Module Dependencies + +**Rule:** Modules can depend on `auth`, but NOT on each other. + +``` +auth (required) + ↓ +dashboard (required - layout) + ↓ +todos (optional - example feature) +``` + +**Why?** +- Prevents circular dependencies +- Makes modules truly reusable +- Simplifies reasoning about the codebase + +**Shared logic goes in:** +- `/src/lib/` - Utilities +- `/src/services/` - Business logic +- `/src/components/` - Shared UI + +--- + +## Known Issues & Gotchas + +### 1. Radix UI Select Empty String Issue + +**Problem:** Radix UI Select doesn't allow empty string values in SelectItems. + +**Solution:** Use sentinel value like `"__any__"` instead of `""`. + +```typescript +// ❌ Breaks: +Any Category + +// ✅ Works: +Any Category + +// Then handle in logic: +const categoryId = value === "__any__" ? null : value; +``` + +**Location:** Fixed in `src/modules/todos/components/todo-form.tsx:96` + +### 2. Two Terminal Requirement + +**Problem:** Need both Wrangler and Next.js dev servers running. + +**Why:** +- Wrangler provides D1 database access +- Next.js provides hot module reload +- They don't conflict (different ports) + +**Solution:** Use two terminals or `pnpm run dev:cf` (no HMR). + +### 3. D1 Local Database Persistence + +**Location:** `.wrangler/state/v3/d1/miniflare-D1DatabaseObject/` + +**Gotcha:** Local database persists between restarts (good and bad). + +**Good:** Don't lose data when restarting dev server. +**Bad:** Stale data can cause confusion. + +**Reset if needed:** +```bash +rm -rf .wrangler/state +pnpm run db:migrate:local +``` + +### 4. Better-auth Session Cookies + +**Cookie name:** `better-auth.session_token` + +**Gotcha:** Must be logged in to test protected routes. + +**Testing API routes with curl:** +1. Login in browser +2. DevTools → Application → Cookies → Copy session token +3. Use in curl: `-H "Cookie: better-auth.session_token=xxx"` + +### 5. Cloudflare Account Selection + +**Problem (after @opennextjs/cloudflare 1.11.1 upgrade):** + +Wrangler dev may show account selection error if you have 100+ Cloudflare accounts. + +**Solution:** Set `CLOUDFLARE_ACCOUNT_ID` in `.dev.vars` (already done). + +### 6. Build Cache Stale Modules + +**Problem:** After upgrading dependencies, old build cache can cause errors. + +**Solution:** +```bash +rm -rf .next .worker-next +pnpm run build:cf +``` + +**When:** After any dependency upgrade or wrangler.jsonc changes. + +### 7. Next.js 16 Upgrade Not Possible Yet + +**Blockers:** +- better-auth: No Next.js 16 support (issue #5263) +- @opennextjs/cloudflare: Proxy system partially supported (issue #972) + +**Timeline:** Reassess Q1 2026 + +**See:** docs/NEXTJS_16_UPGRADE.md for full research + +--- + +## Common Commands Quick Reference + +### Development + +```bash +# Start dev (two terminals) +pnpm run wrangler:dev # Terminal 1 +pnpm run dev # Terminal 2 + +# Start dev (single terminal, no HMR) +pnpm run dev:cf + +# Start dev with remote Cloudflare resources +pnpm run dev:remote +``` + +### Database + +```bash +# Generate migration +pnpm run db:generate:named "description" + +# Apply migrations +pnpm run db:migrate:local # Development +pnpm run db:migrate:preview # Preview env +pnpm run db:migrate:prod # Production + +# Inspect database +pnpm run db:inspect:local +pnpm run db:studio:local + +# Reset local database (WARNING: deletes data) +pnpm run db:reset:local +``` + +### Build & Deploy + +```bash +# Build for Cloudflare +pnpm run build:cf + +# Deploy to preview +pnpm run deploy:preview + +# Deploy to production +pnpm run deploy + +# Generate Cloudflare types (after wrangler.jsonc changes) +pnpm run cf-typegen +``` + +### Secrets Management + +```bash +# Add secret to Cloudflare Workers +pnpm run cf:secret BETTER_AUTH_SECRET + +# Or use wrangler directly: +echo "secret-value" | wrangler secret put SECRET_NAME +``` + +### Code Quality + +```bash +# Format code with Biome +pnpm run lint +``` + +--- + +## File Structure Reference + +``` +├── src/ +│ ├── app/ # Next.js App Router pages +│ │ ├── (auth)/ # Auth pages (login, signup) +│ │ ├── api/ # API routes +│ │ └── dashboard/ # Protected pages +│ ├── components/ # Shared UI components +│ │ └── ui/ # shadcn/ui components +│ ├── db/ # Database configuration +│ │ ├── index.ts # DB connection +│ │ └── schema.ts # Central schema exports +│ ├── modules/ # Feature modules (see MODULES.md) +│ │ ├── auth/ # Authentication (required) +│ │ ├── dashboard/ # Dashboard layout (required) +│ │ └── todos/ # Example CRUD (optional) +│ ├── lib/ # Shared utilities +│ └── drizzle/ # Database migrations +├── docs/ # Documentation +│ ├── API_ENDPOINTS.md +│ ├── DATABASE_SCHEMA.md +│ ├── IMPLEMENTATION_PHASES.md +│ └── NEXTJS_16_UPGRADE.md +├── MODULES.md # Module system guide +├── MODULE_TEMPLATE.md # How to create modules +├── CLAUDE.md # This file +├── README.md # Project README +├── SESSION.md # Session state tracking +├── package.json # Dependencies & scripts +├── wrangler.jsonc # Cloudflare config +├── .dev.vars # Local secrets (gitignored) +└── .env.example # Environment template +``` + +--- + +## When Context Clears + +**If you're a fresh Claude Code session reading this:** + +1. **Read this file first** - You're doing it! +2. **Check SESSION.md** - Understand current work state +3. **Review MODULES.md** - Understand architecture +4. **Check recent commits** - `git log --oneline -10` +5. **Check docs/** - Project-specific documentation +6. **Ask the user** - What are we working on today? + +**Key commands to understand current state:** +```bash +git status # What's changed +git log --oneline -5 # Recent work +git branch -a # Available branches +ls -la docs/ # Available documentation +``` + +--- + +## Contributing to This Project + +**Before making changes:** + +1. **Read MODULES.md** - Understand module system +2. **Check existing patterns** - Follow established conventions +3. **Test manually** - Use checklist above +4. **Build before committing** - `pnpm run build:cf` +5. **Write descriptive commits** - Follow conventional commits + +**Module changes:** +- Keep modules self-contained +- Update `src/db/schema.ts` if adding tables +- Generate migrations for schema changes +- Update MODULES.md if changing architecture + +**Documentation changes:** +- Update this file if workflow changes +- Update README.md for user-facing changes +- Update MODULE_TEMPLATE.md if adding new patterns + +--- + +## Resources + +**Project Documentation:** +- [README.md](./README.md) - Setup and deployment +- [MODULES.md](./MODULES.md) - Module system guide +- [MODULE_TEMPLATE.md](./MODULE_TEMPLATE.md) - Create new modules +- [SESSION.md](./SESSION.md) - Current session state + +**Technical Documentation:** +- [Next.js 15 Docs](https://nextjs.org/docs) +- [Cloudflare Workers](https://developers.cloudflare.com/workers/) +- [Drizzle ORM](https://orm.drizzle.team/docs/overview) +- [better-auth](https://www.better-auth.com/docs) +- [shadcn/ui](https://ui.shadcn.com/) +- [Tailwind v4](https://tailwindcss.com/docs) + +**Cloudflare Specific:** +- [D1 Documentation](https://developers.cloudflare.com/d1/) +- [R2 Storage](https://developers.cloudflare.com/r2/) +- [Workers AI](https://developers.cloudflare.com/workers-ai/) +- [Wrangler CLI](https://developers.cloudflare.com/workers/wrangler/) + +--- + +**Last Updated:** 2025-11-08 +**Maintainer:** Jez (jeremy@jezweb.net) +**Claude Code Version:** This file is optimized for Claude Code CLI diff --git a/MODULES.md b/MODULES.md new file mode 100644 index 0000000..38bd73d --- /dev/null +++ b/MODULES.md @@ -0,0 +1,699 @@ +# Module System Guide + +**Version:** 1.0.0 +**Last Updated:** 2025-11-08 + +--- + +## Overview + +This project uses a **feature-based module architecture** where each feature is self-contained within its own module directory. This makes it easy to: + +- **Add new features** by copying the module pattern +- **Remove unwanted features** by deleting the module folder +- **Reuse features** across different projects +- **Maintain code** with clear separation of concerns + +**Architecture Pattern:** Similar to T3 Stack, Next.js Boilerplate, and other production Next.js applications. + +--- + +## Module Structure + +``` +src/modules/ +├── auth/ ← Required (authentication & user management) +├── dashboard/ ← Required (protected page layout) +└── todos/ ← Optional (example CRUD feature) +``` + +Each module follows this internal structure: + +``` +module-name/ +├── actions/ ← Server actions (mutations, queries) +├── components/ ← React components +├── models/ ← Database queries (if needed) +├── schemas/ ← Zod validation schemas +├── utils/ ← Helper functions (if needed) +├── hooks/ ← Custom React hooks (if needed) +├── [name].page.tsx ← Page component (if needed) +├── [name].layout.tsx ← Layout component (if needed) +└── [name].route.ts ← Route configuration (if needed) +``` + +--- + +## Available Modules + +### ✅ Required Modules + +#### 1. **auth** - Authentication & User Management + +**Purpose:** Handles user authentication, session management, and authorization. + +**Dependencies:** +- better-auth (authentication library) +- Cloudflare D1 (user/session storage) + +**Database Tables:** +- `user` - User accounts +- `session` - Active sessions +- `account` - OAuth accounts +- `verification` - Email verification tokens + +**Key Files:** +- `src/modules/auth/schemas/auth.schema.ts` - Database schemas +- `src/modules/auth/actions/auth.action.ts` - Login/signup actions +- `src/modules/auth/utils/auth-client.ts` - Better-auth client +- `src/modules/auth/components/login-form.tsx` - Login UI +- `src/modules/auth/components/signup-form.tsx` - Signup UI + +**Routes:** +- `/login` - Login page +- `/signup` - Signup page +- `/api/auth/*` - Better-auth API routes + +**Can be removed?** ❌ No - Required for user authentication + +**Alternative:** Replace with different auth (Clerk, Auth.js, custom JWT) + +--- + +#### 2. **dashboard** - Protected Page Layout + +**Purpose:** Main layout for authenticated users. + +**Dependencies:** +- auth module (for session checking) + +**Database Tables:** None + +**Key Files:** +- `src/modules/dashboard/dashboard.layout.tsx` - Main layout +- `src/modules/dashboard/dashboard.page.tsx` - Dashboard home +- `src/modules/dashboard/dashboard.route.ts` - Route config + +**Routes:** +- `/dashboard` - Main dashboard page + +**Can be removed?** ⚠️ Only if you create alternative protected layout + +--- + +### 📦 Optional Modules + +#### 3. **todos** - Todo CRUD Example + +**Purpose:** Example feature demonstrating full CRUD operations with categories. + +**Dependencies:** +- auth module (for user context) +- Cloudflare D1 (data storage) + +**Database Tables:** +- `todos` - Todo items +- `categories` - Todo categories + +**Key Files:** +- `src/modules/todos/schemas/todo.schema.ts` - Todo database schema +- `src/modules/todos/schemas/category.schema.ts` - Category schema +- `src/modules/todos/actions/get-todos.action.ts` - Fetch todos +- `src/modules/todos/actions/create-todo.action.ts` - Create todo +- `src/modules/todos/actions/update-todo.action.ts` - Update todo +- `src/modules/todos/actions/delete-todo.action.ts` - Delete todo +- `src/modules/todos/actions/create-category.action.ts` - Create category +- `src/modules/todos/components/todo-card.tsx` - Todo display +- `src/modules/todos/components/todo-form.tsx` - Todo editor +- `src/modules/todos/components/add-category.tsx` - Category creator + +**Routes:** +- `/dashboard/todos` - Todo list page +- `/dashboard/todos/new` - Create todo page +- `/dashboard/todos/[id]` - Edit todo page + +**Can be removed?** ✅ Yes - This is an example feature + +**How to remove:** See "Removing a Module" section below + +--- + +## How to Remove a Module + +### Example: Removing the Todos Module + +**Step 1: Delete the module folder** +```bash +rm -rf src/modules/todos +``` + +**Step 2: Remove database schemas** + +Edit `src/db/schema.ts`: +```typescript +// Before: +export { categories } from "@/modules/todos/schemas/category.schema"; +export { todos } from "@/modules/todos/schemas/todo.schema"; + +// After: (remove the above lines) +``` + +**Step 3: Remove routes** + +Delete or update files that reference the todos module: +- `src/app/dashboard/todos/` - Delete entire folder +- Any imports of todos components or actions + +**Step 4: Generate new migration** + +If you've already run migrations with the todos tables: +```bash +# Option A: Generate migration to drop tables +pnpm run db:generate:named "remove_todos" + +# Then manually edit the migration to drop tables: +# DROP TABLE IF EXISTS todos; +# DROP TABLE IF EXISTS categories; + +# Option B: Reset local database entirely +pnpm run db:reset:local +``` + +**Step 5: Test** +```bash +pnpm run dev +# Verify app works without the module +``` + +--- + +## How to Add a New Module + +### Example: Adding an "Invoices" Module + +**Step 1: Create module folder** +```bash +mkdir -p src/modules/invoices/{actions,components,models,schemas} +``` + +**Step 2: Create database schema** + +Create `src/modules/invoices/schemas/invoice.schema.ts`: +```typescript +import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; +import { createInsertSchema, createSelectSchema } from "drizzle-zod"; +import { z } from "zod"; + +export const invoices = sqliteTable("invoices", { + id: text("id").primaryKey(), + userId: text("user_id").notNull(), + amount: integer("amount").notNull(), + status: text("status").notNull(), // 'draft', 'sent', 'paid' + createdAt: text("created_at").notNull(), + updatedAt: text("updated_at").notNull(), +}); + +export const insertInvoiceSchema = createInsertSchema(invoices); +export const selectInvoiceSchema = createSelectSchema(invoices); +``` + +**Step 3: Export schema** + +Add to `src/db/schema.ts`: +```typescript +export { invoices } from "@/modules/invoices/schemas/invoice.schema"; +``` + +**Step 4: Generate migration** +```bash +pnpm run db:generate:named "add_invoices" +pnpm run db:migrate:local +``` + +**Step 5: Create server actions** + +Create `src/modules/invoices/actions/get-invoices.action.ts`: +```typescript +"use server"; + +import { db } from "@/db"; +import { invoices } from "@/db/schema"; +import { auth } from "@/modules/auth/utils/auth-utils"; +import { eq } from "drizzle-orm"; + +export async function getInvoices() { + const session = await auth(); + + if (!session?.user) { + return { success: false, error: "Unauthorized", data: null }; + } + + try { + const userInvoices = await db + .select() + .from(invoices) + .where(eq(invoices.userId, session.user.id)); + + return { success: true, data: userInvoices, error: null }; + } catch (error) { + console.error("Error fetching invoices:", error); + return { success: false, error: "Failed to fetch invoices", data: null }; + } +} +``` + +**Step 6: Create components** + +Create `src/modules/invoices/components/invoice-list.tsx`: +```typescript +import { getInvoices } from "../actions/get-invoices.action"; + +export async function InvoiceList() { + const { data: invoices, error } = await getInvoices(); + + if (error) { + return
Error: {error}
; + } + + return ( +
+ {invoices?.map((invoice) => ( +
+ Invoice #{invoice.id} - ${invoice.amount / 100} +
+ ))} +
+ ); +} +``` + +**Step 7: Create routes** + +Create `src/app/dashboard/invoices/page.tsx`: +```typescript +import { InvoiceList } from "@/modules/invoices/components/invoice-list"; + +export default function InvoicesPage() { + return ( +
+

Invoices

+ +
+ ); +} +``` + +**Step 8: Test** +```bash +pnpm run dev +# Visit http://localhost:3000/dashboard/invoices +``` + +--- + +## Module Best Practices + +### ✅ DO: + +1. **Keep modules self-contained** + - All module code stays in `/src/modules/[name]/` + - Minimize dependencies on other modules (except auth) + +2. **Follow consistent structure** + - Use the same folder pattern: actions/, components/, schemas/, models/ + - Name files descriptively: `get-todos.action.ts`, `todo-card.tsx` + +3. **Export schemas properly** + - Add to `src/db/schema.ts` for Drizzle to detect + - Use `createInsertSchema` and `createSelectSchema` for type safety + +4. **Use Server Actions** + - All mutations through Server Actions (not API routes) + - Add `"use server"` directive at top of action files + +5. **Validate with Zod** + - Create schemas for all input data + - Use `zodResolver` in forms + +6. **Handle authentication** + - Check `auth()` in all server actions + - Return proper error responses for unauthorized access + +### ❌ DON'T: + +1. **Don't create circular dependencies** + - Modules shouldn't depend on each other (except auth) + - Extract shared logic to `/src/lib/` or `/src/services/` + +2. **Don't bypass the module structure** + - Keep all feature code in the module folder + - Don't scatter components across multiple directories + +3. **Don't skip database migrations** + - Always generate migrations for schema changes + - Test migrations locally before production + +4. **Don't hardcode user IDs** + - Always use `session.user.id` from `auth()` + - Filter database queries by user ID + +5. **Don't expose internal module details** + - Export only what's needed + - Keep implementation details private + +--- + +## Module Dependencies + +``` +┌─────────────┐ +│ auth │ ← Required by all modules +└─────────────┘ + ↑ + │ +┌──────┴──────┐ +│ dashboard │ ← Layout for protected pages +└─────────────┘ + ↑ + │ +┌──────┴──────┐ +│ todos │ ← Example feature module +└─────────────┘ +``` + +**Rule:** Modules can depend on `auth`, but not on each other. + +If two modules need shared logic, extract it to: +- `/src/lib/` - Shared utilities +- `/src/services/` - Business logic services +- `/src/components/` - Shared UI components + +--- + +## Database Integration + +### Schema Location + +Database schemas live in modules but are exported centrally: + +```typescript +// src/modules/todos/schemas/todo.schema.ts +export const todos = sqliteTable("todos", { ... }); + +// src/db/schema.ts (central export) +export { todos } from "@/modules/todos/schemas/todo.schema"; +``` + +This allows: +- ✅ Drizzle to generate migrations from all schemas +- ✅ Modules to import only what they need +- ✅ Type safety across the entire app + +### Migration Workflow + +**When adding a module with database tables:** +```bash +# 1. Create schema in module +# 2. Export from src/db/schema.ts +# 3. Generate migration +pnpm run db:generate:named "add_invoices_table" + +# 4. Apply locally +pnpm run db:migrate:local + +# 5. Verify +pnpm run db:inspect:local + +# 6. Commit migration file +git add src/drizzle/*.sql +git commit -m "feat: add invoices table" +``` + +**When removing a module with database tables:** +```bash +# 1. Generate drop table migration +pnpm run db:generate:named "remove_invoices_table" + +# 2. Manually edit migration to drop tables +# In src/drizzle/XXXX_remove_invoices_table.sql: +# DROP TABLE IF EXISTS invoices; + +# 3. Apply locally +pnpm run db:migrate:local + +# Or reset entirely: +pnpm run db:reset:local +``` + +--- + +## Type Safety + +### Automatic Type Generation + +Database types are automatically generated from schemas: + +```typescript +// src/modules/todos/schemas/todo.schema.ts +export const insertTodoSchema = createInsertSchema(todos); +export const selectTodoSchema = createSelectSchema(todos); + +// Usage in actions: +import { insertTodoSchema } from "../schemas/todo.schema"; + +export async function createTodo(data: z.infer) { + // Type-safe input +} +``` + +### Cloudflare Bindings + +After any `wrangler.jsonc` changes: +```bash +pnpm run cf-typegen +``` + +This generates types for: +- D1 database bindings +- R2 bucket bindings +- KV namespace bindings +- Workers AI bindings + +--- + +## Testing a Module + +### Manual Testing Checklist + +When creating a new module, test: + +- [ ] **Server Actions work** + - Can create/read/update/delete items + - Authentication is enforced + - Error handling works + +- [ ] **UI renders correctly** + - Components display data + - Forms submit properly + - Loading states work + +- [ ] **Database integration** + - Migrations applied successfully + - Queries return expected data + - User isolation works (users see only their data) + +- [ ] **Type safety** + - No TypeScript errors + - Form validation works + - Zod schemas validate input + +### Example Test Flow (Invoices Module) + +```bash +# 1. Start dev servers +pnpm run wrangler:dev # Terminal 1 +pnpm run dev # Terminal 2 + +# 2. Login at http://localhost:3000 +# 3. Navigate to /dashboard/invoices +# 4. Create an invoice +# 5. Verify it appears in the list +# 6. Edit the invoice +# 7. Delete the invoice + +# 8. Check database +pnpm run db:studio:local +# Verify invoice was created/updated/deleted +``` + +--- + +## Reusing Modules Across Projects + +### Option 1: Fork This Repository + +**Best for:** Starting new projects from scratch + +```bash +# 1. Fork this repo +git clone https://github.com/your-username/fullstack-next-cloudflare.git my-new-app +cd my-new-app + +# 2. Remove unwanted modules (e.g., todos) +rm -rf src/modules/todos +# Edit src/db/schema.ts to remove todo exports +# Delete src/app/dashboard/todos/ + +# 3. Add your own modules +mkdir -p src/modules/invoices +# Follow "How to Add a New Module" guide + +# 4. Build your app! +``` + +--- + +### Option 2: Copy Module to Existing Project + +**Best for:** Adding a feature to an existing Next.js + Cloudflare app + +```bash +# 1. Copy module folder +cp -r /path/to/this-repo/src/modules/todos /path/to/your-project/src/modules/ + +# 2. Copy schema +# From: src/modules/todos/schemas/*.schema.ts +# To: your-project/src/modules/todos/schemas/ + +# 3. Export schema in your src/db/schema.ts +export { todos } from "@/modules/todos/schemas/todo.schema"; +export { categories } from "@/modules/todos/schemas/category.schema"; + +# 4. Generate migration +cd /path/to/your-project +pnpm run db:generate:named "add_todos" +pnpm run db:migrate:local + +# 5. Copy routes +cp -r /path/to/this-repo/src/app/dashboard/todos /path/to/your-project/src/app/dashboard/ + +# 6. Test +pnpm run dev +``` + +--- + +## Common Issues & Solutions + +### Issue: "Module not found" errors + +**Cause:** TypeScript can't resolve module imports + +**Solution:** +```bash +# Rebuild the app +rm -rf .next +pnpm run build:cf +``` + +--- + +### Issue: Database table doesn't exist + +**Cause:** Migrations weren't applied + +**Solution:** +```bash +# Check migration status +pnpm run db:inspect:local + +# Apply migrations +pnpm run db:migrate:local + +# If stuck, reset: +pnpm run db:reset:local +``` + +--- + +### Issue: "User is not authenticated" errors + +**Cause:** Session not passed to server action + +**Solution:** +- Ensure you're calling `auth()` in server actions +- Check that Better Auth is configured correctly +- Verify session cookies in DevTools + +--- + +### Issue: Type errors after removing a module + +**Cause:** Stale imports referencing deleted module + +**Solution:** +```bash +# Search for remaining imports +grep -r "from.*todos" src/ + +# Remove all references to the deleted module +# Rebuild +pnpm run build:cf +``` + +--- + +## Architecture Comparison + +### This Approach (Feature Modules) + +``` +✅ Simple to understand +✅ Easy to copy/paste modules +✅ Low maintenance overhead +✅ Works for small-medium apps +✅ No special tooling required +``` + +### Plugin System Approach + +``` +❌ 50-100+ hours to build +❌ High maintenance burden +❌ Overkill for most projects +✅ Good for CMSes or marketplaces +✅ Dynamic loading at runtime +``` + +**Recommendation:** Stick with feature modules unless you're building a CMS or marketplace platform. + +--- + +## Examples from Other Projects + +**Similar architecture in production:** + +- **T3 Stack** - Feature-based modules, fork-and-customize +- **Next.js Boilerplate** - Module organization, delete what you don't need +- **Supermemory.ai** - This template's inspiration, modular Cloudflare stack + +--- + +## Module Template + +See `MODULE_TEMPLATE.md` for a complete step-by-step guide to creating a new module from scratch. + +--- + +## Questions or Issues? + +If you have questions about the module system or run into issues: + +1. Check this guide for common solutions +2. Review the `todos` module as a reference implementation +3. See `MODULE_TEMPLATE.md` for detailed examples +4. Open an issue in the GitHub repository + +--- + +**Happy building!** 🚀 diff --git a/MODULE_TEMPLATE.md b/MODULE_TEMPLATE.md new file mode 100644 index 0000000..dbbec0b --- /dev/null +++ b/MODULE_TEMPLATE.md @@ -0,0 +1,1058 @@ +# Module Template Guide + +**Version:** 1.0.0 +**Purpose:** Step-by-step guide for creating a new feature module + +--- + +## Overview + +This guide walks you through creating a complete feature module from scratch using the established patterns in this project. + +**Example:** We'll build an "Invoices" module to demonstrate all the concepts. + +**Time estimate:** 30-60 minutes for your first module, 15-30 minutes once familiar + +--- + +## Before You Start + +**Prerequisites:** +- ✅ Project is set up and running locally +- ✅ You understand the basics of Next.js App Router +- ✅ You've reviewed the `todos` module as a reference +- ✅ Database migrations are working + +**Recommended:** Review [MODULES.md](./MODULES.md) first for architecture overview. + +--- + +## Step 1: Plan Your Module + +### Define the Module + +Before writing code, answer these questions: + +**What is the feature?** +- Example: "Invoice management system" + +**What data does it store?** +- Example: Invoice with amount, status, due date, customer info + +**What actions can users take?** +- Example: Create invoice, view invoices, update invoice, delete invoice, send invoice + +**Does it depend on other modules?** +- Almost always depends on: `auth` (for user context) +- Avoid depending on: other feature modules + +**What routes does it need?** +- Example: + - `/dashboard/invoices` - List all invoices + - `/dashboard/invoices/new` - Create new invoice + - `/dashboard/invoices/[id]` - View/edit invoice + +--- + +## Step 2: Create Module Structure + +### Create Folders + +```bash +# Create the module directory structure +mkdir -p src/modules/invoices/{actions,components,models,schemas,utils} +``` + +**Result:** +``` +src/modules/invoices/ +├── actions/ ← Server actions (create, read, update, delete) +├── components/ ← React components +├── models/ ← Database query helpers (optional) +├── schemas/ ← Zod schemas and database tables +└── utils/ ← Helper functions (optional) +``` + +--- + +## Step 3: Define Database Schema + +### Create Schema File + +Create `src/modules/invoices/schemas/invoice.schema.ts`: + +```typescript +import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; +import { createInsertSchema, createSelectSchema } from "drizzle-zod"; +import { z } from "zod"; + +/** + * Invoice table schema + */ +export const invoices = sqliteTable("invoices", { + // Primary key + id: text("id").primaryKey(), + + // Foreign key to user + userId: text("user_id").notNull(), + + // Invoice data + invoiceNumber: text("invoice_number").notNull(), + customerName: text("customer_name").notNull(), + customerEmail: text("customer_email"), + amount: integer("amount").notNull(), // Amount in cents + status: text("status").notNull(), // 'draft', 'sent', 'paid', 'overdue' + dueDate: text("due_date").notNull(), + + // Metadata + createdAt: text("created_at").notNull(), + updatedAt: text("updated_at").notNull(), +}); + +/** + * Zod schemas for validation + */ +export const insertInvoiceSchema = createInsertSchema(invoices, { + invoiceNumber: z.string().min(1, "Invoice number is required"), + customerName: z.string().min(1, "Customer name is required"), + customerEmail: z.string().email("Invalid email").optional(), + amount: z.number().positive("Amount must be positive"), + status: z.enum(["draft", "sent", "paid", "overdue"]), + dueDate: z.string().min(1, "Due date is required"), +}); + +export const selectInvoiceSchema = createSelectSchema(invoices); + +/** + * TypeScript types + */ +export type Invoice = z.infer; +export type InsertInvoice = z.infer; +``` + +**Key points:** +- Use `text()` for strings and IDs (D1 is SQLite) +- Use `integer()` for numbers (store money in cents to avoid floating point issues) +- Add `.notNull()` for required fields +- Create Zod schemas with `createInsertSchema` and `createSelectSchema` +- Add custom validation in Zod schema if needed + +--- + +### Export Schema Centrally + +Edit `src/db/schema.ts` and add: + +```typescript +export { invoices } from "@/modules/invoices/schemas/invoice.schema"; +``` + +**Full file should look like:** +```typescript +export { + account, + session, + user, + verification, +} from "@/modules/auth/schemas/auth.schema"; +export { categories } from "@/modules/todos/schemas/category.schema"; +export { todos } from "@/modules/todos/schemas/todo.schema"; +export { invoices } from "@/modules/invoices/schemas/invoice.schema"; // ← Add this +``` + +--- + +### Generate and Apply Migration + +```bash +# Generate migration from schema changes +pnpm run db:generate:named "add_invoices_table" + +# Apply migration to local database +pnpm run db:migrate:local + +# Verify table was created +pnpm run db:inspect:local +# Should show "invoices" in the table list +``` + +--- + +## Step 4: Create Server Actions + +### Action 1: Get All Invoices + +Create `src/modules/invoices/actions/get-invoices.action.ts`: + +```typescript +"use server"; + +import { db } from "@/db"; +import { invoices } from "@/db/schema"; +import { auth } from "@/modules/auth/utils/auth-utils"; +import { desc, eq } from "drizzle-orm"; + +export async function getInvoices() { + // 1. Check authentication + const session = await auth(); + + if (!session?.user) { + return { success: false, error: "Unauthorized", data: null }; + } + + try { + // 2. Query database (filtered by user ID) + const userInvoices = await db + .select() + .from(invoices) + .where(eq(invoices.userId, session.user.id)) + .orderBy(desc(invoices.createdAt)); + + // 3. Return success response + return { success: true, data: userInvoices, error: null }; + } catch (error) { + console.error("Error fetching invoices:", error); + return { success: false, error: "Failed to fetch invoices", data: null }; + } +} +``` + +--- + +### Action 2: Create Invoice + +Create `src/modules/invoices/actions/create-invoice.action.ts`: + +```typescript +"use server"; + +import { db } from "@/db"; +import { invoices } from "@/db/schema"; +import { auth } from "@/modules/auth/utils/auth-utils"; +import { insertInvoiceSchema } from "../schemas/invoice.schema"; +import { revalidatePath } from "next/cache"; + +export async function createInvoice(data: unknown) { + // 1. Check authentication + const session = await auth(); + + if (!session?.user) { + return { success: false, error: "Unauthorized", data: null }; + } + + try { + // 2. Validate input + const validatedData = insertInvoiceSchema.parse(data); + + // 3. Generate ID and timestamps + const now = new Date().toISOString(); + const invoice = { + ...validatedData, + id: crypto.randomUUID(), + userId: session.user.id, + createdAt: now, + updatedAt: now, + }; + + // 4. Insert into database + await db.insert(invoices).values(invoice); + + // 5. Revalidate the invoices page + revalidatePath("/dashboard/invoices"); + + // 6. Return success + return { success: true, data: invoice, error: null }; + } catch (error) { + console.error("Error creating invoice:", error); + + // Handle Zod validation errors + if (error instanceof Error && error.name === "ZodError") { + return { success: false, error: "Invalid invoice data", data: null }; + } + + return { success: false, error: "Failed to create invoice", data: null }; + } +} +``` + +--- + +### Action 3: Update Invoice + +Create `src/modules/invoices/actions/update-invoice.action.ts`: + +```typescript +"use server"; + +import { db } from "@/db"; +import { invoices } from "@/db/schema"; +import { auth } from "@/modules/auth/utils/auth-utils"; +import { and, eq } from "drizzle-orm"; +import { insertInvoiceSchema } from "../schemas/invoice.schema"; +import { revalidatePath } from "next/cache"; + +export async function updateInvoice(id: string, data: unknown) { + const session = await auth(); + + if (!session?.user) { + return { success: false, error: "Unauthorized", data: null }; + } + + try { + // Validate input + const validatedData = insertInvoiceSchema.partial().parse(data); + + // Update only if invoice belongs to user + await db + .update(invoices) + .set({ + ...validatedData, + updatedAt: new Date().toISOString(), + }) + .where( + and( + eq(invoices.id, id), + eq(invoices.userId, session.user.id) + ) + ); + + revalidatePath("/dashboard/invoices"); + revalidatePath(`/dashboard/invoices/${id}`); + + return { success: true, data: { id }, error: null }; + } catch (error) { + console.error("Error updating invoice:", error); + return { success: false, error: "Failed to update invoice", data: null }; + } +} +``` + +--- + +### Action 4: Delete Invoice + +Create `src/modules/invoices/actions/delete-invoice.action.ts`: + +```typescript +"use server"; + +import { db } from "@/db"; +import { invoices } from "@/db/schema"; +import { auth } from "@/modules/auth/utils/auth-utils"; +import { and, eq } from "drizzle-orm"; +import { revalidatePath } from "next/cache"; + +export async function deleteInvoice(id: string) { + const session = await auth(); + + if (!session?.user) { + return { success: false, error: "Unauthorized" }; + } + + try { + // Delete only if invoice belongs to user + await db + .delete(invoices) + .where( + and( + eq(invoices.id, id), + eq(invoices.userId, session.user.id) + ) + ); + + revalidatePath("/dashboard/invoices"); + + return { success: true, error: null }; + } catch (error) { + console.error("Error deleting invoice:", error); + return { success: false, error: "Failed to delete invoice" }; + } +} +``` + +--- + +### Action 5: Get Single Invoice + +Create `src/modules/invoices/actions/get-invoice-by-id.action.ts`: + +```typescript +"use server"; + +import { db } from "@/db"; +import { invoices } from "@/db/schema"; +import { auth } from "@/modules/auth/utils/auth-utils"; +import { and, eq } from "drizzle-orm"; + +export async function getInvoiceById(id: string) { + const session = await auth(); + + if (!session?.user) { + return { success: false, error: "Unauthorized", data: null }; + } + + try { + const invoice = await db + .select() + .from(invoices) + .where( + and( + eq(invoices.id, id), + eq(invoices.userId, session.user.id) + ) + ) + .limit(1); + + if (!invoice || invoice.length === 0) { + return { success: false, error: "Invoice not found", data: null }; + } + + return { success: true, data: invoice[0], error: null }; + } catch (error) { + console.error("Error fetching invoice:", error); + return { success: false, error: "Failed to fetch invoice", data: null }; + } +} +``` + +--- + +## Step 5: Create Components + +### Component 1: Invoice List + +Create `src/modules/invoices/components/invoice-list.tsx`: + +```typescript +import { getInvoices } from "../actions/get-invoices.action"; +import { InvoiceCard } from "./invoice-card"; + +export async function InvoiceList() { + const { data: invoices, error } = await getInvoices(); + + if (error) { + return ( +
+ Error loading invoices: {error} +
+ ); + } + + if (!invoices || invoices.length === 0) { + return ( +
+ No invoices yet. Create your first invoice! +
+ ); + } + + return ( +
+ {invoices.map((invoice) => ( + + ))} +
+ ); +} +``` + +--- + +### Component 2: Invoice Card + +Create `src/modules/invoices/components/invoice-card.tsx`: + +```typescript +import { Card } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import Link from "next/link"; +import type { Invoice } from "../schemas/invoice.schema"; +import { DeleteInvoice } from "./delete-invoice"; + +interface InvoiceCardProps { + invoice: Invoice; +} + +export function InvoiceCard({ invoice }: InvoiceCardProps) { + const statusColors = { + draft: "bg-secondary", + sent: "bg-blue-500", + paid: "bg-green-500", + overdue: "bg-destructive", + }; + + return ( + +
+
+
+

+ Invoice #{invoice.invoiceNumber} +

+ + {invoice.status} + +
+

+ {invoice.customerName} +

+

+ ${(invoice.amount / 100).toFixed(2)} +

+

+ Due: {new Date(invoice.dueDate).toLocaleDateString()} +

+
+ +
+ + + + +
+
+
+ ); +} +``` + +--- + +### Component 3: Invoice Form + +Create `src/modules/invoices/components/invoice-form.tsx`: + +```typescript +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; +import { useRouter } from "next/navigation"; +import { useState, useTransition } from "react"; +import toast from "react-hot-toast"; +import { Button } from "@/components/ui/button"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { createInvoice } from "../actions/create-invoice.action"; +import { updateInvoice } from "../actions/update-invoice.action"; +import { insertInvoiceSchema, type Invoice } from "../schemas/invoice.schema"; + +interface InvoiceFormProps { + invoice?: Invoice; + mode: "create" | "edit"; +} + +export function InvoiceForm({ invoice, mode }: InvoiceFormProps) { + const router = useRouter(); + const [isPending, startTransition] = useTransition(); + + const form = useForm({ + resolver: zodResolver(insertInvoiceSchema), + defaultValues: { + invoiceNumber: invoice?.invoiceNumber || "", + customerName: invoice?.customerName || "", + customerEmail: invoice?.customerEmail || "", + amount: invoice?.amount || 0, + status: invoice?.status || "draft", + dueDate: invoice?.dueDate || "", + }, + }); + + const onSubmit = (data: any) => { + startTransition(async () => { + const result = + mode === "create" + ? await createInvoice(data) + : await updateInvoice(invoice!.id, data); + + if (!result.success) { + toast.error(result.error || "Operation failed"); + return; + } + + toast.success( + mode === "create" + ? "Invoice created!" + : "Invoice updated!" + ); + router.push("/dashboard/invoices"); + }); + }; + + return ( +
+ + ( + + Invoice Number + + + + + + )} + /> + + ( + + Customer Name + + + + + + )} + /> + + ( + + Customer Email (Optional) + + + + + + )} + /> + + ( + + Amount (cents) + + + field.onChange(Number(e.target.value)) + } + /> + + + + )} + /> + + ( + + Status + + + + )} + /> + + ( + + Due Date + + + + + + )} + /> + +
+ + +
+ + + ); +} +``` + +--- + +### Component 4: Delete Invoice Button + +Create `src/modules/invoices/components/delete-invoice.tsx`: + +```typescript +"use client"; + +import { useState, useTransition } from "react"; +import { useRouter } from "next/navigation"; +import toast from "react-hot-toast"; +import { Trash2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; +import { deleteInvoice } from "../actions/delete-invoice.action"; + +interface DeleteInvoiceProps { + invoiceId: string; +} + +export function DeleteInvoice({ invoiceId }: DeleteInvoiceProps) { + const router = useRouter(); + const [open, setOpen] = useState(false); + const [isPending, startTransition] = useTransition(); + + const handleDelete = () => { + startTransition(async () => { + const result = await deleteInvoice(invoiceId); + + if (!result.success) { + toast.error(result.error || "Failed to delete invoice"); + return; + } + + toast.success("Invoice deleted"); + setOpen(false); + router.refresh(); + }); + }; + + return ( + + + + + + + Delete Invoice + + Are you sure? This action cannot be undone. + + + + + Cancel + + + {isPending ? "Deleting..." : "Delete"} + + + + + ); +} +``` + +--- + +## Step 6: Create Routes + +### Route 1: Invoice List Page + +Create `src/app/dashboard/invoices/page.tsx`: + +```typescript +import { Button } from "@/components/ui/button"; +import { InvoiceList } from "@/modules/invoices/components/invoice-list"; +import { Plus } from "lucide-react"; +import Link from "next/link"; + +export default function InvoicesPage() { + return ( +
+
+

Invoices

+ + + +
+ + +
+ ); +} +``` + +--- + +### Route 2: Create Invoice Page + +Create `src/app/dashboard/invoices/new/page.tsx`: + +```typescript +import { InvoiceForm } from "@/modules/invoices/components/invoice-form"; + +export default function NewInvoicePage() { + return ( +
+

Create Invoice

+ +
+ ); +} +``` + +--- + +### Route 3: Edit Invoice Page + +Create `src/app/dashboard/invoices/[id]/page.tsx`: + +```typescript +import { notFound } from "next/navigation"; +import { getInvoiceById } from "@/modules/invoices/actions/get-invoice-by-id.action"; +import { InvoiceForm } from "@/modules/invoices/components/invoice-form"; + +interface EditInvoicePageProps { + params: Promise<{ id: string }>; +} + +export default async function EditInvoicePage({ params }: EditInvoicePageProps) { + const { id } = await params; + const { data: invoice, error } = await getInvoiceById(id); + + if (error || !invoice) { + notFound(); + } + + return ( +
+

Edit Invoice

+ +
+ ); +} +``` + +--- + +## Step 7: Test Your Module + +### Start Dev Servers + +```bash +# Terminal 1 +pnpm run wrangler:dev + +# Terminal 2 +pnpm run dev +``` + +### Manual Testing Checklist + +- [ ] Visit `http://localhost:3000/dashboard/invoices` +- [ ] Click "New Invoice" button +- [ ] Fill out the form and create an invoice +- [ ] Verify invoice appears in the list +- [ ] Click "Edit" on an invoice +- [ ] Update the invoice details +- [ ] Verify changes are saved +- [ ] Click delete button +- [ ] Confirm deletion works +- [ ] Check database: `pnpm run db:studio:local` +- [ ] Verify data isolation (create second user, ensure they see only their invoices) + +--- + +## Common Patterns + +### Pattern: Optimistic UI Updates + +For better UX, you can update the UI before the server responds: + +```typescript +"use client"; + +import { useOptimistic } from "react"; + +export function InvoiceList({ initialInvoices }) { + const [optimisticInvoices, addOptimisticInvoice] = useOptimistic( + initialInvoices, + (state, newInvoice) => [...state, newInvoice] + ); + + // Use optimisticInvoices in your JSX +} +``` + +--- + +### Pattern: Loading States + +Show skeletons while data loads: + +```typescript +import { Suspense } from "react"; +import { InvoiceListSkeleton } from "./invoice-list-skeleton"; + +export default function InvoicesPage() { + return ( + }> + + + ); +} +``` + +--- + +### Pattern: Error Boundaries + +Catch errors gracefully: + +Create `src/app/dashboard/invoices/error.tsx`: + +```typescript +"use client"; + +import { Button } from "@/components/ui/button"; + +export default function InvoicesError({ + error, + reset, +}: { + error: Error; + reset: () => void; +}) { + return ( +
+

Something went wrong!

+

{error.message}

+ +
+ ); +} +``` + +--- + +## Deployment Checklist + +Before deploying your new module: + +- [ ] All server actions have authentication checks +- [ ] Database queries filter by `userId` +- [ ] Migrations have been tested locally +- [ ] Forms have proper validation +- [ ] Error handling is in place +- [ ] Loading states exist +- [ ] Delete actions have confirmation dialogs +- [ ] No sensitive data exposed in client components +- [ ] TypeScript has no errors (`pnpm run build`) + +--- + +## Next Steps + +**You now have a complete CRUD module!** + +**Optional enhancements:** +- Add search/filter functionality +- Implement pagination +- Add sorting options +- Create export to PDF feature +- Add email notifications +- Implement recurring invoices + +**Copy this pattern for other modules:** +- Projects +- Clients +- Products +- Orders +- etc. + +--- + +## Reference + +**See existing modules for examples:** +- `src/modules/todos` - Full CRUD example +- `src/modules/auth` - Authentication patterns +- `src/modules/dashboard` - Layout patterns + +**Documentation:** +- [MODULES.md](./MODULES.md) - Module system overview +- [README.md](./README.md) - Project setup +- [Next.js Docs](https://nextjs.org/docs) - Framework reference +- [Drizzle ORM](https://orm.drizzle.team/docs/overview) - Database patterns + +--- + +**You're all set!** Follow this template for any new feature module you want to add. 🚀 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..92c27a1 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ # ⚡ Full-Stack Next.js + Cloudflare Template -A production-ready template for building full-stack applications with Next.js 15 and Cloudflare's powerful edge infrastructure. Perfect for MVPs with generous free tiers and seamless scaling to enterprise-level applications. +A **modular starter kit** for building full-stack applications with Next.js 15 and Cloudflare's powerful edge infrastructure. Perfect for MVPs with generous free tiers and seamless scaling to enterprise-level applications. **Inspired by the [Cloudflare SaaS Stack](https://github.com/supermemoryai/cloudflare-saas-stack)** - the same stack powering [Supermemory.ai](https://git.new/memory), which serves 20k+ users on just $5/month. This template modernizes that approach with Cloudflare Workers (vs Pages), includes comprehensive D1 and R2 examples, and provides a complete development workflow. @@ -10,6 +10,41 @@ You can read detail explanations and code architecture of this template from Dev Don't forget to leave a star if you find this helpful ⭐️ +--- + +## 🧩 Modular Architecture + +This template uses a **feature-based module system** that makes it easy to: + +- ✅ **Start new projects** - Fork this repo and customize modules +- ✅ **Remove unwanted features** - Delete module folders you don't need +- ✅ **Add new features** - Follow the established pattern for consistency +- ✅ **Reuse modules** - Copy modules to other projects + +**See [MODULES.md](./MODULES.md)** for complete documentation on: +- Available modules (auth, todos, dashboard) +- How to remove a module +- How to add a new module +- Best practices and common issues + +**Quick Start for New Projects:** +```bash +# 1. Fork this repository +git clone https://github.com/your-username/fullstack-next-cloudflare.git my-app +cd my-app + +# 2. Remove unwanted modules (optional) +rm -rf src/modules/todos +# See MODULES.md for complete removal steps + +# 3. Install and configure +pnpm install +cp .dev.vars.example .dev.vars +# Edit .dev.vars with your credentials + +# 4. Start building your app! +``` + ## 🌟 Why Cloudflare + Next.js? @@ -149,6 +184,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 +299,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 +369,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 +444,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/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/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 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/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!, }, }); diff --git a/package.json b/package.json index 7c5f3e8..a8f0b2e 100644 --- a/package.json +++ b/package.json @@ -31,11 +31,12 @@ }, "dependencies": { "@hookform/resolvers": "^5.2.1", - "@opennextjs/cloudflare": "^1.3.0", + "@opennextjs/cloudflare": "^1.11.1", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-label": "^2.1.7", + "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-slot": "^1.2.3", @@ -48,7 +49,9 @@ "drizzle-zod": "^0.8.3", "lucide-react": "^0.544.0", "next": "15.4.6", + "next-themes": "^0.4.6", "react": "19.1.0", + "react-colorful": "^5.6.1", "react-dom": "19.1.0", "react-hook-form": "^7.62.0", "react-hot-toast": "^2.6.0", @@ -65,6 +68,6 @@ "tailwindcss": "^4", "tw-animate-css": "^1.3.8", "typescript": "^5", - "wrangler": "^4.35.0" + "wrangler": "^4.46.0" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 11a3696..9187dd7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,8 +12,8 @@ importers: specifier: ^5.2.1 version: 5.2.1(react-hook-form@7.62.0(react@19.1.0)) '@opennextjs/cloudflare': - specifier: ^1.3.0 - version: 1.8.2(wrangler@4.35.0) + specifier: ^1.11.1 + version: 1.11.1(wrangler@4.46.0) '@radix-ui/react-alert-dialog': specifier: ^1.1.15 version: 1.1.15(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) @@ -26,6 +26,9 @@ importers: '@radix-ui/react-label': specifier: ^2.1.7 version: 2.1.7(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-popover': + specifier: ^1.1.15 + version: 1.1.15(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-select': specifier: ^2.2.6 version: 2.2.6(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) @@ -62,9 +65,15 @@ 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 + react-colorful: + specifier: ^5.6.1 + version: 5.6.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0) react-dom: specifier: 19.1.0 version: 19.1.0(react@19.1.0) @@ -109,8 +118,8 @@ importers: specifier: ^5 version: 5.9.2 wrangler: - specifier: ^4.35.0 - version: 4.35.0 + specifier: ^4.46.0 + version: 4.46.0 packages: @@ -524,41 +533,41 @@ packages: resolution: {integrity: sha512-+tv3z+SPp+gqTIcImN9o0hqE9xyfQjI1XD9pL6NuKjua9B1y7mNYv0S9cP+QEbA4ppVgGZEmKOvHX5G5Ei1CVA==} engines: {node: '>=18.0.0'} - '@cloudflare/unenv-preset@2.7.3': - resolution: {integrity: sha512-tsQQagBKjvpd9baa6nWVIv399ejiqcrUBBW6SZx6Z22+ymm+Odv5+cFimyuCsD/fC1fQTwfRmwXBNpzvHSeGCw==} + '@cloudflare/unenv-preset@2.7.9': + resolution: {integrity: sha512-Drm7qlTKnvncEv+DANiQNEonq0H0LyIsoFZYJ6tJ8OhAoy5udIE8yp6BsVDYcIjcYLIybp4M7c/P7ly/56SoHg==} peerDependencies: - unenv: 2.0.0-rc.21 - workerd: ^1.20250828.1 + unenv: 2.0.0-rc.24 + workerd: ^1.20250927.0 peerDependenciesMeta: workerd: optional: true - '@cloudflare/workerd-darwin-64@1.20250906.0': - resolution: {integrity: sha512-E+X/YYH9BmX0ew2j/mAWFif2z05NMNuhCTlNYEGLkqMe99K15UewBqajL9pMcMUKxylnlrEoK3VNxl33DkbnPA==} + '@cloudflare/workerd-darwin-64@1.20251105.0': + resolution: {integrity: sha512-nztUP35wTtUKM+681dBWtUNSySNWELTV+LY43oWy7ZhK19/iBJPQoFY7xpvF7zy4qOOShtise259B65DS4/71Q==} engines: {node: '>=16'} cpu: [x64] os: [darwin] - '@cloudflare/workerd-darwin-arm64@1.20250906.0': - resolution: {integrity: sha512-X5apsZ1SFW4FYTM19ISHf8005FJMPfrcf4U5rO0tdj+TeJgQgXuZ57IG0WeW7SpLVeBo8hM6WC8CovZh41AfnA==} + '@cloudflare/workerd-darwin-arm64@1.20251105.0': + resolution: {integrity: sha512-WS/dvPYTW/+gs8s0UvDqDY7wcuIAg/hUpjrMNGepr+Mo38vMU39FYhJQOly99oJCXxMluQqAnRKg09b/9Gr+Rg==} engines: {node: '>=16'} cpu: [arm64] os: [darwin] - '@cloudflare/workerd-linux-64@1.20250906.0': - resolution: {integrity: sha512-rlKzWgsLnlQ5Nt9W69YBJKcmTmZbOGu0edUsenXPmc6wzULUxoQpi7ZE9k3TfTonJx4WoQsQlzCUamRYFsX+0Q==} + '@cloudflare/workerd-linux-64@1.20251105.0': + resolution: {integrity: sha512-RdHRHo/hpjR6sNw529FkmslVSz/K3Pb1+i3fIoqUrHCrZOUYzFyz3nLeZh4EYaAhcztLWiSTwBv54bcl4sG3wA==} engines: {node: '>=16'} cpu: [x64] os: [linux] - '@cloudflare/workerd-linux-arm64@1.20250906.0': - resolution: {integrity: sha512-DdedhiQ+SeLzpg7BpcLrIPEZ33QKioJQ1wvL4X7nuLzEB9rWzS37NNNahQzc1+44rhG4fyiHbXBPOeox4B9XVA==} + '@cloudflare/workerd-linux-arm64@1.20251105.0': + resolution: {integrity: sha512-5zkxQCqLjwrqZVVJh92J2Drv6xifkP8kN2ltjHdwZQlVzfDW48d7tAtCm1ZooUv204ixvZFarusCfL+IRjExZg==} engines: {node: '>=16'} cpu: [arm64] os: [linux] - '@cloudflare/workerd-windows-64@1.20250906.0': - resolution: {integrity: sha512-Q8Qjfs8jGVILnZL6vUpQ90q/8MTCYaGR3d1LGxZMBqte8Vr7xF3KFHPEy7tFs0j0mMjnqCYzlofmPNY+9ZaDRg==} + '@cloudflare/workerd-windows-64@1.20251105.0': + resolution: {integrity: sha512-6BpkfjBIbGR+4FBOcZGcWDLM0XQuoI6R9Dublj/BKf4pv0/xJ4zHdnaYUb5NIlC75L55Ouqw0CEJasoKlMjgnw==} engines: {node: '>=16'} cpu: [x64] os: [win32] @@ -1467,15 +1476,15 @@ packages: resolution: {integrity: sha512-csY4qcR7jUwiZmkreNTJhcypQfts2aY2CK+a+rXgXUImZiZiySh0FvwHjRnlqWKvg+y6ae9lHFzDRjBTmqlTIQ==} engines: {node: '>=16.0.0'} - '@opennextjs/aws@3.7.6': - resolution: {integrity: sha512-l4UGkmaZaAjWER+PuZ/zNziMnrbf6oA9B1RUDKcyKsX+hEES1b7h6kLgpo4a4maf01M+k0yJUZQyF4sf+vI+Iw==} + '@opennextjs/aws@3.8.5': + resolution: {integrity: sha512-elpMb0fJZc0a1VtymedFa7P1lYcyOmt+Pwqyacpq2C/SvbETIeBlW/Xle/vY95ICtccKUxITI8MtonrCo2+2/Q==} hasBin: true - '@opennextjs/cloudflare@1.8.2': - resolution: {integrity: sha512-Og2UvU1EEkQuxK7b+FlJU0zoJKQTIAw85bHo/JGEkXK0hA4MABkb/deuido785yXd91JQU7ayGB3Ql9Cs6jwSQ==} + '@opennextjs/cloudflare@1.11.1': + resolution: {integrity: sha512-KBZ8AdTwzFDfNCfE8mwNtBzdvA7tuFG8nGK5Eqi4WJ8C7tZQygG0++6t3XF5uZfTUeD+omFz42zlbI5pLK0WDg==} hasBin: true peerDependencies: - wrangler: ^4.24.4 + wrangler: ^4.38.0 '@opentelemetry/api@1.9.0': resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==} @@ -1660,6 +1669,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-popover@1.1.15': + resolution: {integrity: sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-popper@1.2.8': resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==} peerDependencies: @@ -2307,6 +2329,9 @@ packages: '@types/node@20.19.13': resolution: {integrity: sha512-yCAeZl7a0DxgNVteXFHt9+uyFbqXGy/ShC4BlcHkoE0AfGXYv/BUiplV72DjMYXHDBXFjhvr6DD1NiRVfB4j8g==} + '@types/rclone.js@0.6.3': + resolution: {integrity: sha512-BssKAAVRY//fxGKso8SatyOwiD7X0toDofNnVxZlIXmN7UHrn2UBTxldNAjgUvWA91qJyeEPfKmeJpZVhLugXg==} + '@types/react-dom@19.1.9': resolution: {integrity: sha512-qXRuZaOsAdXKFyOhRBg6Lqqc0yay13vN7KrIg4L7N4aaHN68ma9OK3NE1BoDFgFOTfM7zg+3/8+2n8rLUH3OKQ==} peerDependencies: @@ -2343,6 +2368,10 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + adm-zip@0.5.16: + resolution: {integrity: sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ==} + engines: {node: '>=12.0'} + agentkeepalive@4.6.0: resolution: {integrity: sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==} engines: {node: '>= 8.0.0'} @@ -2801,9 +2830,6 @@ packages: resolution: {integrity: sha512-ORF7g6qGnD+YtUG9yx4DFoqCShNMmUKiXuT5oWMHiOvt/4WFbHC6yCwQMTSBMno7AqntNCAzzcnnjowRkTL9eQ==} engines: {node: '>= 18'} - exsolve@1.0.7: - resolution: {integrity: sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw==} - fast-xml-parser@4.2.5: resolution: {integrity: sha512-B9/wizE4WngqQftFPmdaMYlXoJlJOYxGQOanC77fq9k8+Z0v5dDSVh+3glErdIROP//s/jgb7ZuxKfB8nVyo0g==} hasBin: true @@ -3154,8 +3180,8 @@ packages: resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} engines: {node: '>=10'} - miniflare@4.20250906.0: - resolution: {integrity: sha512-T/RWn1sa0ien80s6NjU+Un/tj12gR6wqScZoiLeMJDD4/fK0UXfnbWXJDubnUED8Xjm7RPQ5ESYdE+mhPmMtuQ==} + miniflare@4.20251105.0: + resolution: {integrity: sha512-n+lCQbGLPjHFm5EKMohxCl+hLIki9rIlJSU9FkYKdJ62cGacetmTH5IgWUZhUFFM+NqhqZLOuWXTAsoZTm0hog==} engines: {node: '>=18.0.0'} hasBin: true @@ -3198,6 +3224,10 @@ packages: mnemonist@0.38.3: resolution: {integrity: sha512-2K9QYubXx/NAjv4VLq1d1Ly8pWNC5L3BrixtdkyTegXWJIqY+zLNDhhX/A+ZwWt70tB1S8H4BE8FLYEFyNoOBw==} + mri@1.2.0: + resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} + engines: {node: '>=4'} + ms@2.1.2: resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==} @@ -3220,6 +3250,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} @@ -3278,9 +3314,6 @@ packages: obliterator@1.6.1: resolution: {integrity: sha512-9WXswnqINnnhOG/5SLimUlzuU1hFJUc8zkwyD59Sd+dPOMf05PmnYG/d6Q7HZ+KmgkZJa1PxRso6QdM3sTNHig==} - ohash@2.0.11: - resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==} - on-finished@2.4.1: resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} engines: {node: '>= 0.8'} @@ -3377,6 +3410,19 @@ packages: resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} hasBin: true + rclone.js@0.6.6: + resolution: {integrity: sha512-Dxh34cab/fNjFq5SSm0fYLNkGzG2cQSBy782UW9WwxJCEiVO4cGXkvaXcNlgv817dK8K8PuQ+NHUqSAMMhWujQ==} + engines: {node: '>=12'} + cpu: [arm, arm64, mips, mipsel, x32, x64] + os: [darwin, freebsd, linux, openbsd, sunos, win32] + hasBin: true + + react-colorful@5.6.1: + resolution: {integrity: sha512-1exovf0uGTGyq5mXQT0zgQ80uvj2PCwvF8zY1RN9/vbJVSjSo3fsB/4L3ObbF7u70NduSiK4xu4Y6q1MHoUGEw==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + react-dom@19.1.0: resolution: {integrity: sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==} peerDependencies: @@ -3649,9 +3695,6 @@ packages: engines: {node: '>=14.17'} hasBin: true - ufo@1.6.1: - resolution: {integrity: sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==} - uncrypto@0.1.3: resolution: {integrity: sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==} @@ -3661,12 +3704,12 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} - undici@7.16.0: - resolution: {integrity: sha512-QEg3HPMll0o3t2ourKwOeUAZ159Kn9mx5pnzHRQO8+Wixmh88YdZRiIwat0iNzNNXn0yoEtXJqFpyW7eM8BV7g==} + undici@7.14.0: + resolution: {integrity: sha512-Vqs8HTzjpQXZeXdpsfChQTlafcMQaaIwnGwLam1wudSSjlJeQ3bw1j+TLPePgrCnCpUXx7Ba5Pdpf5OBih62NQ==} engines: {node: '>=20.18.1'} - unenv@2.0.0-rc.21: - resolution: {integrity: sha512-Wj7/AMtE9MRnAXa6Su3Lk0LNCfqDYgfwVjwRFVum9U7wsto1imuHqk4kTm7Jni+5A0Hn7dttL6O/zjvUvoo+8A==} + unenv@2.0.0-rc.24: + resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==} unpipe@1.0.0: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} @@ -3734,17 +3777,17 @@ packages: engines: {node: ^16.13.0 || >=18.0.0} hasBin: true - workerd@1.20250906.0: - resolution: {integrity: sha512-ryVyEaqXPPsr/AxccRmYZZmDAkfQVjhfRqrNTlEeN8aftBk6Ca1u7/VqmfOayjCXrA+O547TauebU+J3IpvFXw==} + workerd@1.20251105.0: + resolution: {integrity: sha512-8D1UmsxrRr3Go7enbYCsYoiWeGn66u1WFNojPSgtjp7z8pV2cXskjr05vQ1OOzl7+rg1hDDofnCJqVwChMym8g==} engines: {node: '>=16'} hasBin: true - wrangler@4.35.0: - resolution: {integrity: sha512-HbyXtbrh4Fi3mU8ussY85tVdQ74qpVS1vctUgaPc+bPrXBTqfDLkZ6VRtHAVF/eBhz4SFmhJtCQpN1caY2Ak8A==} + wrangler@4.46.0: + resolution: {integrity: sha512-WRROO7CL+MW/E44RMT4X7w32qPjufiPpGdey5D6H7iKzzVqfUkTRULxYBfWANiU1yGnsiCXQtu3Ap0G2TmohtA==} engines: {node: '>=18.0.0'} hasBin: true peerDependencies: - '@cloudflare/workers-types': ^4.20250906.0 + '@cloudflare/workers-types': ^4.20251014.0 peerDependenciesMeta: '@cloudflare/workers-types': optional: true @@ -3895,7 +3938,7 @@ snapshots: '@aws-crypto/sha256-js': 3.0.0 '@aws-crypto/supports-web-crypto': 3.0.0 '@aws-crypto/util': 3.0.0 - '@aws-sdk/types': 3.398.0 + '@aws-sdk/types': 3.887.0 '@aws-sdk/util-locate-window': 3.873.0 '@aws-sdk/util-utf8-browser': 3.259.0 tslib: 1.14.1 @@ -3913,7 +3956,7 @@ snapshots: '@aws-crypto/sha256-js@3.0.0': dependencies: '@aws-crypto/util': 3.0.0 - '@aws-sdk/types': 3.398.0 + '@aws-sdk/types': 3.887.0 tslib: 1.14.1 '@aws-crypto/sha256-js@5.2.0': @@ -3932,7 +3975,7 @@ snapshots: '@aws-crypto/util@3.0.0': dependencies: - '@aws-sdk/types': 3.398.0 + '@aws-sdk/types': 3.887.0 '@aws-sdk/util-utf8-browser': 3.259.0 tslib: 1.14.1 @@ -4882,25 +4925,25 @@ snapshots: dependencies: mime: 3.0.0 - '@cloudflare/unenv-preset@2.7.3(unenv@2.0.0-rc.21)(workerd@1.20250906.0)': + '@cloudflare/unenv-preset@2.7.9(unenv@2.0.0-rc.24)(workerd@1.20251105.0)': dependencies: - unenv: 2.0.0-rc.21 + unenv: 2.0.0-rc.24 optionalDependencies: - workerd: 1.20250906.0 + workerd: 1.20251105.0 - '@cloudflare/workerd-darwin-64@1.20250906.0': + '@cloudflare/workerd-darwin-64@1.20251105.0': optional: true - '@cloudflare/workerd-darwin-arm64@1.20250906.0': + '@cloudflare/workerd-darwin-arm64@1.20251105.0': optional: true - '@cloudflare/workerd-linux-64@1.20250906.0': + '@cloudflare/workerd-linux-64@1.20251105.0': optional: true - '@cloudflare/workerd-linux-arm64@1.20250906.0': + '@cloudflare/workerd-linux-arm64@1.20251105.0': optional: true - '@cloudflare/workerd-windows-64@1.20250906.0': + '@cloudflare/workerd-windows-64@1.20251105.0': optional: true '@cspotcode/source-map-support@0.8.1': @@ -5517,7 +5560,7 @@ snapshots: dependencies: gzip-size: 6.0.0 - '@opennextjs/aws@3.7.6': + '@opennextjs/aws@3.8.5': dependencies: '@ast-grep/napi': 0.35.0 '@aws-sdk/client-cloudfront': 3.398.0 @@ -5540,15 +5583,17 @@ snapshots: - aws-crt - supports-color - '@opennextjs/cloudflare@1.8.2(wrangler@4.35.0)': + '@opennextjs/cloudflare@1.11.1(wrangler@4.46.0)': dependencies: '@dotenvx/dotenvx': 1.31.0 - '@opennextjs/aws': 3.7.6 + '@opennextjs/aws': 3.8.5 + '@types/rclone.js': 0.6.3 cloudflare: 4.5.0 enquirer: 2.4.1 glob: 11.0.3 + rclone.js: 0.6.6 ts-tqdm: 0.8.6 - wrangler: 4.35.0 + wrangler: 4.46.0 yargs: 18.0.0 transitivePeerDependencies: - aws-crt @@ -5744,6 +5789,29 @@ snapshots: '@types/react': 19.1.12 '@types/react-dom': 19.1.9(@types/react@19.1.12) + '@radix-ui/react-popover@1.1.15(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.12)(react@19.1.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.1.12)(react@19.1.0) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.1.12)(react@19.1.0) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-id': 1.1.1(@types/react@19.1.12)(react@19.1.0) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-slot': 1.2.3(@types/react@19.1.12)(react@19.1.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.12)(react@19.1.0) + aria-hidden: 1.2.6 + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + react-remove-scroll: 2.7.1(@types/react@19.1.12)(react@19.1.0) + optionalDependencies: + '@types/react': 19.1.12 + '@types/react-dom': 19.1.9(@types/react@19.1.12) + '@radix-ui/react-popper@1.2.8(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@floating-ui/react-dom': 2.1.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0) @@ -6576,6 +6644,10 @@ snapshots: dependencies: undici-types: 6.21.0 + '@types/rclone.js@0.6.3': + dependencies: + '@types/node': 20.19.13 + '@types/react-dom@19.1.9(@types/react@19.1.12)': dependencies: '@types/react': 19.1.12 @@ -6606,6 +6678,8 @@ snapshots: acorn@8.15.0: {} + adm-zip@0.5.16: {} + agentkeepalive@4.6.0: dependencies: humanize-ms: 1.2.1 @@ -7062,8 +7136,6 @@ snapshots: transitivePeerDependencies: - supports-color - exsolve@1.0.7: {} - fast-xml-parser@4.2.5: dependencies: strnum: 1.1.2 @@ -7365,7 +7437,7 @@ snapshots: mimic-response@3.1.0: {} - miniflare@4.20250906.0: + miniflare@4.20251105.0: dependencies: '@cspotcode/source-map-support': 0.8.1 acorn: 8.14.0 @@ -7374,8 +7446,8 @@ snapshots: glob-to-regexp: 0.4.1 sharp: 0.33.5 stoppable: 1.1.0 - undici: 7.16.0 - workerd: 1.20250906.0 + undici: 7.14.0 + workerd: 1.20251105.0 ws: 8.18.0 youch: 4.1.0-beta.10 zod: 3.22.3 @@ -7411,6 +7483,8 @@ snapshots: dependencies: obliterator: 1.6.1 + mri@1.2.0: {} + ms@2.1.2: {} ms@2.1.3: {} @@ -7423,6 +7497,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 @@ -7474,8 +7553,6 @@ snapshots: obliterator@1.6.1: {} - ohash@2.0.11: {} - on-finished@2.4.1: dependencies: ee-first: 1.1.1 @@ -7584,6 +7661,16 @@ snapshots: minimist: 1.2.8 strip-json-comments: 2.0.1 + rclone.js@0.6.6: + dependencies: + adm-zip: 0.5.16 + mri: 1.2.0 + + react-colorful@5.6.1(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) + react-dom@19.1.0(react@19.1.0): dependencies: react: 19.1.0 @@ -7659,7 +7746,7 @@ snapshots: send@1.2.0: dependencies: - debug: 4.3.6 + debug: 4.4.1 encodeurl: 2.0.0 escape-html: 1.0.3 etag: 1.8.1 @@ -7911,23 +7998,17 @@ snapshots: typescript@5.9.2: {} - ufo@1.6.1: {} - uncrypto@0.1.3: {} undici-types@5.26.5: {} undici-types@6.21.0: {} - undici@7.16.0: {} + undici@7.14.0: {} - unenv@2.0.0-rc.21: + unenv@2.0.0-rc.24: dependencies: - defu: 6.1.4 - exsolve: 1.0.7 - ohash: 2.0.11 pathe: 2.0.3 - ufo: 1.6.1 unpipe@1.0.0: {} @@ -7976,24 +8057,24 @@ snapshots: dependencies: isexe: 3.1.1 - workerd@1.20250906.0: + workerd@1.20251105.0: optionalDependencies: - '@cloudflare/workerd-darwin-64': 1.20250906.0 - '@cloudflare/workerd-darwin-arm64': 1.20250906.0 - '@cloudflare/workerd-linux-64': 1.20250906.0 - '@cloudflare/workerd-linux-arm64': 1.20250906.0 - '@cloudflare/workerd-windows-64': 1.20250906.0 + '@cloudflare/workerd-darwin-64': 1.20251105.0 + '@cloudflare/workerd-darwin-arm64': 1.20251105.0 + '@cloudflare/workerd-linux-64': 1.20251105.0 + '@cloudflare/workerd-linux-arm64': 1.20251105.0 + '@cloudflare/workerd-windows-64': 1.20251105.0 - wrangler@4.35.0: + wrangler@4.46.0: dependencies: '@cloudflare/kv-asset-handler': 0.4.0 - '@cloudflare/unenv-preset': 2.7.3(unenv@2.0.0-rc.21)(workerd@1.20250906.0) + '@cloudflare/unenv-preset': 2.7.9(unenv@2.0.0-rc.24)(workerd@1.20251105.0) blake3-wasm: 2.1.5 esbuild: 0.25.4 - miniflare: 4.20250906.0 + miniflare: 4.20251105.0 path-to-regexp: 6.3.0 - unenv: 2.0.0-rc.21 - workerd: 1.20250906.0 + unenv: 2.0.0-rc.24 + workerd: 1.20251105.0 optionalDependencies: fsevents: 2.3.3 transitivePeerDependencies: 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..5c60818 100644 --- a/src/components/navigation.tsx +++ b/src/components/navigation.tsx @@ -2,27 +2,30 @@ 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"; +import { ModeToggle } from "@/components/mode-toggle"; export function Navigation() { return ( -