From 3acc7410d2863af1b64f420599568f02148a9260 Mon Sep 17 00:00:00 2001 From: Jez Date: Sat, 8 Nov 2025 15:15:46 +1100 Subject: [PATCH 01/10] refactor: standardize createCategory error response pattern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update createCategory action to return success/error object instead of throwing errors, making it consistent with other mutation actions like deleteTodoAction and updateTodoFieldAction. Changes: - create-category.action.ts: Return { success, data?, error? } instead of throwing - add-category.tsx: Update to handle new response structure without try/catch - Added authentication error handling for consistency Benefits: - Consistent error handling across all non-form mutation actions - Cleaner client code (no try/catch needed) - Better error propagation to UI - Matches established pattern used by delete and update field actions This brings all programmatic mutations to use the same response pattern, while form actions with redirect() continue to use throw pattern (as required by Next.js). πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../todos/actions/create-category.action.ts | 38 +++++++++++++++---- src/modules/todos/components/add-category.tsx | 21 ++++------ 2 files changed, 38 insertions(+), 21 deletions(-) diff --git a/src/modules/todos/actions/create-category.action.ts b/src/modules/todos/actions/create-category.action.ts index 48eaa2c..5890a19 100644 --- a/src/modules/todos/actions/create-category.action.ts +++ b/src/modules/todos/actions/create-category.action.ts @@ -10,7 +10,11 @@ import { } from "@/modules/todos/schemas/category.schema"; import todosRoutes from "../todos.route"; -export async function createCategory(data: unknown): Promise { +export async function createCategory(data: unknown): Promise<{ + success: boolean; + data?: Category; + error?: string; +}> { try { const user = await requireAuth(); const validatedData = insertCategorySchema.parse({ @@ -30,21 +34,39 @@ export async function createCategory(data: unknown): Promise { .returning(); if (!result[0]) { - throw new Error("Failed to create category"); + return { + success: false, + error: "Failed to create category", + }; } // Revalidate pages that might show categories revalidatePath(todosRoutes.list); revalidatePath(todosRoutes.new); - return result[0]; + return { + success: true, + data: result[0], + }; } catch (error) { console.error("Error creating category:", error); - throw new Error( - error instanceof Error - ? error.message - : "Failed to create category", - ); + if ( + error instanceof Error && + error.message === "Authentication required" + ) { + return { + success: false, + error: "Authentication required", + }; + } + + return { + success: false, + error: + error instanceof Error + ? error.message + : "Failed to create category", + }; } } diff --git a/src/modules/todos/components/add-category.tsx b/src/modules/todos/components/add-category.tsx index 1c90cbd..5a95a5d 100644 --- a/src/modules/todos/components/add-category.tsx +++ b/src/modules/todos/components/add-category.tsx @@ -59,23 +59,18 @@ export function AddCategory({ onCategoryAdded }: AddCategoryProps) { const onSubmit = (data: AddCategoryFormData) => { startTransition(async () => { - try { - const newCategory = await createCategory(data); - onCategoryAdded(newCategory); + const result = await createCategory(data); + if (!result.success) { + toast.error(result.error || "Failed to create category"); + return; + } + + if (result.data) { + onCategoryAdded(result.data); form.reset(); setOpen(false); - toast.success("Category created successfully!"); - } catch (error) { - console.error("Error creating category:", error); - - const errorMessage = - error instanceof Error - ? error.message - : "Failed to create category"; - - toast.error(errorMessage); } }); }; From 20bf287f523c5920a5ca8a8aa70fd8d9c7470d31 Mon Sep 17 00:00:00 2001 From: Jez Date: Sat, 8 Nov 2025 20:34:56 +1100 Subject: [PATCH 02/10] Initial CRM project setup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 complete: Configured new D1 database and planning docs Changes: - Created new D1 database: fullstack-crm (a1d231c7-b7e7-4e7a-aa0e-78a56c2e123a) - Updated wrangler.jsonc with new database ID - Updated drizzle.config.ts with new database ID - Applied initial migrations (user, session, todos, categories tables) - Added planning documentation: * docs/IMPLEMENTATION_PHASES.md (6 phases, ~6-8 hours) * docs/DATABASE_SCHEMA.md (CRM schema documentation) - Verified dev environment works (Next.js + D1 connection) Next: Phase 2 - Create CRM database schema (contacts, tags, deals) πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- docs/DATABASE_SCHEMA.md | 755 ++++++++++++++++++++++++++++++ docs/IMPLEMENTATION_PHASES.md | 836 ++++++++++++++++++++++++++++++++++ drizzle.config.ts | 2 +- wrangler.jsonc | 4 +- 4 files changed, 1594 insertions(+), 3 deletions(-) create mode 100644 docs/DATABASE_SCHEMA.md create mode 100644 docs/IMPLEMENTATION_PHASES.md 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..961cc15 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: "a1d231c7-b7e7-4e7a-aa0e-78a56c2e123a", token: process.env.CLOUDFLARE_D1_TOKEN!, }, }); diff --git a/wrangler.jsonc b/wrangler.jsonc index 525553b..08a3ff0 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -18,8 +18,8 @@ "d1_databases": [ { "binding": "next_cf_app", - "database_name": "next-cf-app", - "database_id": "757a32d1-5779-4f09-bcf3-b268013395d4", + "database_name": "fullstack-crm", + "database_id": "a1d231c7-b7e7-4e7a-aa0e-78a56c2e123a", "migrations_dir": "./src/drizzle" } ], From 67be7125e96092ffbab90291ecf45c87216470b5 Mon Sep 17 00:00:00 2001 From: Jez Date: Sat, 8 Nov 2025 21:19:16 +1100 Subject: [PATCH 03/10] checkpoint: Phase 1 Complete - Project setup finished MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase: 1 - Project Setup Status: Complete Session: Completed initial project setup including D1 database creation, configuration updates, migrations, and dev environment verification Files Changed: - SESSION.md (session tracking initialized) Next: Phase 2 - Create CRM database schema (contacts, tags, deals tables) πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- SESSION.md | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 SESSION.md diff --git a/SESSION.md b/SESSION.md new file mode 100644 index 0000000..11560ca --- /dev/null +++ b/SESSION.md @@ -0,0 +1,48 @@ +# Session State + +**Current Phase**: Phase 2 +**Current Stage**: Implementation +**Last Checkpoint**: aa5606b (2025-11-08) +**Planning Docs**: `docs/IMPLEMENTATION_PHASES.md`, `docs/DATABASE_SCHEMA.md` + +--- + +## Phase 1: Project Setup βœ… +**Completed**: 2025-11-08 | **Checkpoint**: 20bf287 +**Summary**: Cloned project, configured new D1 database (a1d231c7-b7e7-4e7a-aa0e-78a56c2e123a), updated wrangler.jsonc and drizzle.config.ts, applied initial migrations, verified dev environment works. + +## Phase 2: Database Schema πŸ”„ +**Type**: Database | **Started**: 2025-11-08 +**Spec**: `docs/IMPLEMENTATION_PHASES.md#phase-2` + +**Progress**: +- [ ] Create `src/modules/contacts/schemas/` directory +- [ ] Create contact.schema.ts with contactsTable, contactTagsTable, contactsToTagsTable +- [ ] Create `src/modules/deals/schemas/` directory +- [ ] Create deal.schema.ts with dealsTable and dealStageEnum +- [ ] Update `src/db/schema.ts` to export new CRM schemas +- [ ] Generate migration with `pnpm drizzle-kit generate` +- [ ] Review generated SQL in drizzle/ directory +- [ ] Run migration locally +- [ ] Verify tables created in D1 + +**Next Action**: Create src/modules/contacts/schemas/ directory and implement contact.schema.ts with Drizzle schema definitions for contacts, contact_tags, and contacts_to_tags tables + +**Key Files**: +- `src/modules/contacts/schemas/contact.schema.ts` (to be created) +- `src/modules/deals/schemas/deal.schema.ts` (to be created) +- `src/db/schema.ts` (modify to export new schemas) + +**Known Issues**: None + +## Phase 3: Contacts Module ⏸️ +**Spec**: `docs/IMPLEMENTATION_PHASES.md#phase-3` + +## Phase 4: Deals Module ⏸️ +**Spec**: `docs/IMPLEMENTATION_PHASES.md#phase-4` + +## Phase 5: Dashboard Integration ⏸️ +**Spec**: `docs/IMPLEMENTATION_PHASES.md#phase-5` + +## Phase 6: Testing & Documentation ⏸️ +**Spec**: `docs/IMPLEMENTATION_PHASES.md#phase-6` From e4bec486a2fb4ff029dcc3bcd2da62c7dea81e8a Mon Sep 17 00:00:00 2001 From: Jez Date: Sat, 8 Nov 2025 21:55:37 +1100 Subject: [PATCH 04/10] checkpoint: Phase 2 Complete - CRM database schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase: 2 - Database Schema Status: Complete Session: Created all CRM schemas and applied migration successfully Files Changed: - src/modules/contacts/schemas/contact.schema.ts (contacts, tags, junction) - src/modules/deals/models/deal.enum.ts (deal stages enum) - src/modules/deals/schemas/deal.schema.ts (deals schema) - src/db/schema.ts (export new schemas) - src/drizzle/0001_fantastic_captain_flint.sql (migration) - SESSION.md (marked Phase 2 complete) Database Changes: - Created contacts table (11 columns, user_id FK CASCADE) - Created contact_tags table (5 columns, user_id FK CASCADE) - Created contacts_to_tags junction table (composite PK) - Created deals table (11 columns, contact_id FK SET NULL, user_id FK CASCADE) - Applied migration locally and verified all tables exist Next: Phase 3 - Contacts Module (Server Actions + UI components) πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- SESSION.md | 39 +- src/db/schema.ts | 6 + src/drizzle/0001_fantastic_captain_flint.sql | 47 + src/drizzle/meta/0001_snapshot.json | 887 ++++++++++++++++++ src/drizzle/meta/_journal.json | 7 + .../contacts/schemas/contact.schema.ts | 113 +++ src/modules/deals/models/deal.enum.ts | 19 + src/modules/deals/schemas/deal.schema.ts | 70 ++ 8 files changed, 1162 insertions(+), 26 deletions(-) create mode 100644 src/drizzle/0001_fantastic_captain_flint.sql create mode 100644 src/drizzle/meta/0001_snapshot.json create mode 100644 src/modules/contacts/schemas/contact.schema.ts create mode 100644 src/modules/deals/models/deal.enum.ts create mode 100644 src/modules/deals/schemas/deal.schema.ts diff --git a/SESSION.md b/SESSION.md index 11560ca..006e752 100644 --- a/SESSION.md +++ b/SESSION.md @@ -1,8 +1,8 @@ # Session State -**Current Phase**: Phase 2 -**Current Stage**: Implementation -**Last Checkpoint**: aa5606b (2025-11-08) +**Current Phase**: Phase 3 +**Current Stage**: Planning +**Last Checkpoint**: 67be712 (2025-11-08) **Planning Docs**: `docs/IMPLEMENTATION_PHASES.md`, `docs/DATABASE_SCHEMA.md` --- @@ -11,29 +11,16 @@ **Completed**: 2025-11-08 | **Checkpoint**: 20bf287 **Summary**: Cloned project, configured new D1 database (a1d231c7-b7e7-4e7a-aa0e-78a56c2e123a), updated wrangler.jsonc and drizzle.config.ts, applied initial migrations, verified dev environment works. -## Phase 2: Database Schema πŸ”„ -**Type**: Database | **Started**: 2025-11-08 -**Spec**: `docs/IMPLEMENTATION_PHASES.md#phase-2` - -**Progress**: -- [ ] Create `src/modules/contacts/schemas/` directory -- [ ] Create contact.schema.ts with contactsTable, contactTagsTable, contactsToTagsTable -- [ ] Create `src/modules/deals/schemas/` directory -- [ ] Create deal.schema.ts with dealsTable and dealStageEnum -- [ ] Update `src/db/schema.ts` to export new CRM schemas -- [ ] Generate migration with `pnpm drizzle-kit generate` -- [ ] Review generated SQL in drizzle/ directory -- [ ] Run migration locally -- [ ] Verify tables created in D1 - -**Next Action**: Create src/modules/contacts/schemas/ directory and implement contact.schema.ts with Drizzle schema definitions for contacts, contact_tags, and contacts_to_tags tables - -**Key Files**: -- `src/modules/contacts/schemas/contact.schema.ts` (to be created) -- `src/modules/deals/schemas/deal.schema.ts` (to be created) -- `src/db/schema.ts` (modify to export new schemas) - -**Known Issues**: None +## Phase 2: Database Schema βœ… +**Completed**: 2025-11-08 +**Summary**: Created Drizzle schemas for contacts (with tags junction table) and deals. Generated and applied migration 0001_fantastic_captain_flint.sql. Verified all 4 tables created in D1 (contacts, contact_tags, contacts_to_tags, deals) with proper foreign keys, defaults, and data types. + +**Key Files Created**: +- `src/modules/contacts/schemas/contact.schema.ts` (contacts, contactTags, contactsToTags tables + Zod schemas) +- `src/modules/deals/models/deal.enum.ts` (DealStage enum) +- `src/modules/deals/schemas/deal.schema.ts` (deals table + Zod schemas) +- `src/db/schema.ts` (updated exports) +- `src/drizzle/0001_fantastic_captain_flint.sql` (migration) ## Phase 3: Contacts Module ⏸️ **Spec**: `docs/IMPLEMENTATION_PHASES.md#phase-3` diff --git a/src/db/schema.ts b/src/db/schema.ts index c83b9b6..0132798 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -6,3 +6,9 @@ export { } from "@/modules/auth/schemas/auth.schema"; export { categories } from "@/modules/todos/schemas/category.schema"; export { todos } from "@/modules/todos/schemas/todo.schema"; +export { + contacts, + contactTags, + contactsToTags, +} from "@/modules/contacts/schemas/contact.schema"; +export { deals } from "@/modules/deals/schemas/deal.schema"; diff --git a/src/drizzle/0001_fantastic_captain_flint.sql b/src/drizzle/0001_fantastic_captain_flint.sql new file mode 100644 index 0000000..becdbf3 --- /dev/null +++ b/src/drizzle/0001_fantastic_captain_flint.sql @@ -0,0 +1,47 @@ +CREATE TABLE `contact_tags` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `name` text NOT NULL, + `color` text NOT NULL, + `user_id` integer NOT NULL, + `created_at` integer NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE TABLE `contacts` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `first_name` text, + `last_name` text, + `email` text, + `phone` text, + `company` text, + `job_title` text, + `notes` text, + `user_id` integer NOT NULL, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE TABLE `contacts_to_tags` ( + `contact_id` integer NOT NULL, + `tag_id` integer NOT NULL, + PRIMARY KEY(`contact_id`, `tag_id`), + FOREIGN KEY (`contact_id`) REFERENCES `contacts`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`tag_id`) REFERENCES `contact_tags`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE TABLE `deals` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `title` text NOT NULL, + `contact_id` integer, + `value` real NOT NULL, + `currency` text DEFAULT 'AUD' NOT NULL, + `stage` text DEFAULT 'Prospecting' NOT NULL, + `expected_close_date` integer, + `description` text, + `user_id` integer NOT NULL, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL, + FOREIGN KEY (`contact_id`) REFERENCES `contacts`(`id`) ON UPDATE no action ON DELETE set null, + FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade +); diff --git a/src/drizzle/meta/0001_snapshot.json b/src/drizzle/meta/0001_snapshot.json new file mode 100644 index 0000000..d85258d --- /dev/null +++ b/src/drizzle/meta/0001_snapshot.json @@ -0,0 +1,887 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "c0b3741a-7e85-4cb3-95d9-a5b8843389a0", + "prevId": "e832fc23-676d-48c1-84cc-399730ff3178", + "tables": { + "account": { + "name": "account", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast((julianday('now') - 2440587.5)*86400000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "categories": { + "name": "categories", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'#6366f1'" + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "categories_user_id_user_id_fk": { + "name": "categories_user_id_user_id_fk", + "tableFrom": "categories", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "contact_tags": { + "name": "contact_tags", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "contact_tags_user_id_user_id_fk": { + "name": "contact_tags_user_id_user_id_fk", + "tableFrom": "contact_tags", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "contacts": { + "name": "contacts", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "first_name": { + "name": "first_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_name": { + "name": "last_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "company": { + "name": "company", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "job_title": { + "name": "job_title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "contacts_user_id_user_id_fk": { + "name": "contacts_user_id_user_id_fk", + "tableFrom": "contacts", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "contacts_to_tags": { + "name": "contacts_to_tags", + "columns": { + "contact_id": { + "name": "contact_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tag_id": { + "name": "tag_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "contacts_to_tags_contact_id_contacts_id_fk": { + "name": "contacts_to_tags_contact_id_contacts_id_fk", + "tableFrom": "contacts_to_tags", + "tableTo": "contacts", + "columnsFrom": [ + "contact_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "contacts_to_tags_tag_id_contact_tags_id_fk": { + "name": "contacts_to_tags_tag_id_contact_tags_id_fk", + "tableFrom": "contacts_to_tags", + "tableTo": "contact_tags", + "columnsFrom": [ + "tag_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "contacts_to_tags_contact_id_tag_id_pk": { + "columns": [ + "contact_id", + "tag_id" + ], + "name": "contacts_to_tags_contact_id_tag_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "deals": { + "name": "deals", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "contact_id": { + "name": "contact_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'AUD'" + }, + "stage": { + "name": "stage", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'Prospecting'" + }, + "expected_close_date": { + "name": "expected_close_date", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "deals_contact_id_contacts_id_fk": { + "name": "deals_contact_id_contacts_id_fk", + "tableFrom": "deals", + "tableTo": "contacts", + "columnsFrom": [ + "contact_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "deals_user_id_user_id_fk": { + "name": "deals_user_id_user_id_fk", + "tableFrom": "deals", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session": { + "name": "session", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast((julianday('now') - 2440587.5)*86400000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "session_token_unique": { + "name": "session_token_unique", + "columns": [ + "token" + ], + "isUnique": true + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "todos": { + "name": "todos", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "category_id": { + "name": "category_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "priority": { + "name": "priority", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'medium'" + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "image_alt": { + "name": "image_alt", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "completed": { + "name": "completed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "due_date": { + "name": "due_date", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "todos_category_id_categories_id_fk": { + "name": "todos_category_id_categories_id_fk", + "tableFrom": "todos", + "tableTo": "categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "todos_user_id_user_id_fk": { + "name": "todos_user_id_user_id_fk", + "tableFrom": "todos", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email_verified": { + "name": "email_verified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast((julianday('now') - 2440587.5)*86400000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast((julianday('now') - 2440587.5)*86400000 as integer))" + } + }, + "indexes": { + "user_email_unique": { + "name": "user_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "verification": { + "name": "verification", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast((julianday('now') - 2440587.5)*86400000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast((julianday('now') - 2440587.5)*86400000 as integer))" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/src/drizzle/meta/_journal.json b/src/drizzle/meta/_journal.json index 2b068f5..23c1d44 100644 --- a/src/drizzle/meta/_journal.json +++ b/src/drizzle/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1757850562913, "tag": "0000_initial_schemas_migration", "breakpoints": true + }, + { + "idx": 1, + "version": "6", + "when": 1762599280475, + "tag": "0001_fantastic_captain_flint", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/modules/contacts/schemas/contact.schema.ts b/src/modules/contacts/schemas/contact.schema.ts new file mode 100644 index 0000000..ab8440f --- /dev/null +++ b/src/modules/contacts/schemas/contact.schema.ts @@ -0,0 +1,113 @@ +import { integer, primaryKey, sqliteTable, text } from "drizzle-orm/sqlite-core"; +import { createInsertSchema, createSelectSchema } from "drizzle-zod"; +import { z } from "zod"; +import { user } from "@/modules/auth/schemas/auth.schema"; + +// Contacts table +export const contacts = sqliteTable("contacts", { + id: integer("id").primaryKey({ autoIncrement: true }), + firstName: text("first_name"), + lastName: text("last_name"), + email: text("email"), + phone: text("phone"), + company: text("company"), + jobTitle: text("job_title"), + notes: text("notes"), + userId: integer("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + createdAt: integer("created_at", { mode: "number" }) + .notNull() + .$defaultFn(() => Date.now()), + updatedAt: integer("updated_at", { mode: "number" }) + .notNull() + .$defaultFn(() => Date.now()), +}); + +// Contact tags table +export const contactTags = sqliteTable("contact_tags", { + id: integer("id").primaryKey({ autoIncrement: true }), + name: text("name").notNull(), + color: text("color").notNull(), + userId: integer("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + createdAt: integer("created_at", { mode: "number" }) + .notNull() + .$defaultFn(() => Date.now()), +}); + +// Junction table for contacts to tags (many-to-many) +export const contactsToTags = sqliteTable( + "contacts_to_tags", + { + contactId: integer("contact_id") + .notNull() + .references(() => contacts.id, { onDelete: "cascade" }), + tagId: integer("tag_id") + .notNull() + .references(() => contactTags.id, { onDelete: "cascade" }), + }, + (table) => ({ + pk: primaryKey({ columns: [table.contactId, table.tagId] }), + }) +); + +// Zod schemas for validation +export const insertContactSchema = createInsertSchema(contacts, { + firstName: z.string().optional(), + lastName: z.string().optional(), + email: z + .string() + .email("Invalid email format") + .optional() + .or(z.literal("")), + phone: z.string().optional().or(z.literal("")), + company: z.string().optional().or(z.literal("")), + jobTitle: z.string().optional().or(z.literal("")), + notes: z.string().max(5000, "Notes too long").optional().or(z.literal("")), + userId: z.number().min(1, "User ID is required"), +}).refine( + (data) => data.firstName || data.lastName, + { + message: "At least one of firstName or lastName is required", + path: ["firstName"], + } +); + +export const selectContactSchema = createSelectSchema(contacts); + +export const updateContactSchema = insertContactSchema.partial().omit({ + id: true, + userId: true, + createdAt: true, +}); + +export const insertContactTagSchema = createInsertSchema(contactTags, { + name: z.string().min(1, "Tag name is required").max(50, "Tag name too long"), + color: z + .string() + .regex(/^#[0-9A-Fa-f]{6}$/, "Color must be a valid hex code (e.g., #3B82F6)"), + userId: z.number().min(1, "User ID is required"), +}); + +export const selectContactTagSchema = createSelectSchema(contactTags); + +export const updateContactTagSchema = insertContactTagSchema.partial().omit({ + id: true, + userId: true, + createdAt: true, +}); + +export const insertContactToTagSchema = createInsertSchema(contactsToTags, { + contactId: z.number().min(1, "Contact ID is required"), + tagId: z.number().min(1, "Tag ID is required"), +}); + +// Type exports +export type Contact = typeof contacts.$inferSelect; +export type NewContact = typeof contacts.$inferInsert; +export type ContactTag = typeof contactTags.$inferSelect; +export type NewContactTag = typeof contactTags.$inferInsert; +export type ContactToTag = typeof contactsToTags.$inferSelect; +export type NewContactToTag = typeof contactsToTags.$inferInsert; diff --git a/src/modules/deals/models/deal.enum.ts b/src/modules/deals/models/deal.enum.ts new file mode 100644 index 0000000..f670d7d --- /dev/null +++ b/src/modules/deals/models/deal.enum.ts @@ -0,0 +1,19 @@ +export const DealStage = { + PROSPECTING: "Prospecting", + QUALIFICATION: "Qualification", + PROPOSAL: "Proposal", + NEGOTIATION: "Negotiation", + CLOSED_WON: "Closed Won", + CLOSED_LOST: "Closed Lost", +} as const; + +export type DealStageType = (typeof DealStage)[keyof typeof DealStage]; + +export const dealStageEnum = [ + DealStage.PROSPECTING, + DealStage.QUALIFICATION, + DealStage.PROPOSAL, + DealStage.NEGOTIATION, + DealStage.CLOSED_WON, + DealStage.CLOSED_LOST, +] as const; diff --git a/src/modules/deals/schemas/deal.schema.ts b/src/modules/deals/schemas/deal.schema.ts new file mode 100644 index 0000000..3466711 --- /dev/null +++ b/src/modules/deals/schemas/deal.schema.ts @@ -0,0 +1,70 @@ +import { integer, real, sqliteTable, text } from "drizzle-orm/sqlite-core"; +import { createInsertSchema, createSelectSchema } from "drizzle-zod"; +import { z } from "zod"; +import { user } from "@/modules/auth/schemas/auth.schema"; +import { contacts } from "@/modules/contacts/schemas/contact.schema"; +import { + DealStage, + type DealStageType, + dealStageEnum, +} from "@/modules/deals/models/deal.enum"; + +// Deals table +export const deals = sqliteTable("deals", { + id: integer("id").primaryKey({ autoIncrement: true }), + title: text("title").notNull(), + contactId: integer("contact_id").references(() => contacts.id, { + onDelete: "set null", + }), + value: real("value").notNull(), + currency: text("currency").notNull().default("AUD"), + stage: text("stage") + .$type() + .notNull() + .default(DealStage.PROSPECTING), + expectedCloseDate: integer("expected_close_date", { mode: "number" }), + description: text("description"), + userId: integer("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + createdAt: integer("created_at", { mode: "number" }) + .notNull() + .$defaultFn(() => Date.now()), + updatedAt: integer("updated_at", { mode: "number" }) + .notNull() + .$defaultFn(() => Date.now()), +}); + +// Zod schemas for validation +export const insertDealSchema = createInsertSchema(deals, { + title: z + .string() + .min(1, "Deal title is required") + .max(255, "Title too long"), + contactId: z.number().optional(), + value: z + .number() + .min(0, "Value must be positive") + .refine((val) => !Number.isNaN(val), "Value must be a valid number"), + currency: z.string().length(3, "Currency must be a 3-letter ISO code").optional(), + stage: z.enum(dealStageEnum).optional(), + expectedCloseDate: z.number().optional(), + description: z + .string() + .max(5000, "Description too long") + .optional() + .or(z.literal("")), + userId: z.number().min(1, "User ID is required"), +}); + +export const selectDealSchema = createSelectSchema(deals); + +export const updateDealSchema = insertDealSchema.partial().omit({ + id: true, + userId: true, + createdAt: true, +}); + +// Type exports +export type Deal = typeof deals.$inferSelect; +export type NewDeal = typeof deals.$inferInsert; From b34adb7c2e889001d1468039c1327729500fa44a Mon Sep 17 00:00:00 2001 From: Jez Date: Sat, 8 Nov 2025 22:14:35 +1100 Subject: [PATCH 05/10] feat: Phase 3 - Contacts Module (Server Actions + UI) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implemented complete contacts CRUD functionality with search and tag management. Server Actions (5 files): - create-contact.action.ts: Create new contact with validation - get-contacts.action.ts: Fetch contacts with search (LIKE queries) and tag filtering - update-contact.action.ts: Update contact with ownership verification - delete-contact.action.ts: Delete contact with cascade tag removal - tag-management.actions.ts: Full tag CRUD (create, list, assign, remove, delete) UI Components (3 files): - contact-form.tsx: React Hook Form + Zod validation for create/edit - contact-card.tsx: Display contact with tags, email, phone, company - delete-contact.tsx: AlertDialog confirmation for delete Pages (3 routes): - /dashboard/contacts: List page with search input - /dashboard/contacts/new: Create contact form - /dashboard/contacts/[id]/edit: Edit contact form Navigation: - Added "Contacts" link to main navigation with Users icon Features: - Search contacts by name, email, company (case-insensitive LIKE) - Tag support (many-to-many via junction table) - Ownership verification (users can only edit/delete own contacts) - Form validation (at least one name required, email format validation) - Responsive grid layout (1/2/3 columns) - Empty state with call-to-action Build: βœ… No TypeScript errors, all pages compile successfully πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- SESSION.md | 29 ++- src/app/dashboard/contacts/[id]/edit/page.tsx | 42 +++ src/app/dashboard/contacts/new/page.tsx | 16 ++ src/app/dashboard/contacts/page.tsx | 83 ++++++ src/components/navigation.tsx | 8 +- .../contacts/actions/create-contact.action.ts | 60 +++++ .../contacts/actions/delete-contact.action.ts | 46 ++++ .../contacts/actions/get-contacts.action.ts | 112 ++++++++ .../actions/tag-management.actions.ts | 199 ++++++++++++++ .../contacts/actions/update-contact.action.ts | 73 ++++++ .../contacts/components/contact-card.tsx | 104 ++++++++ .../contacts/components/contact-form.tsx | 245 ++++++++++++++++++ .../contacts/components/delete-contact.tsx | 78 ++++++ src/modules/contacts/contacts.route.ts | 7 + 14 files changed, 1100 insertions(+), 2 deletions(-) create mode 100644 src/app/dashboard/contacts/[id]/edit/page.tsx create mode 100644 src/app/dashboard/contacts/new/page.tsx create mode 100644 src/app/dashboard/contacts/page.tsx create mode 100644 src/modules/contacts/actions/create-contact.action.ts create mode 100644 src/modules/contacts/actions/delete-contact.action.ts create mode 100644 src/modules/contacts/actions/get-contacts.action.ts create mode 100644 src/modules/contacts/actions/tag-management.actions.ts create mode 100644 src/modules/contacts/actions/update-contact.action.ts create mode 100644 src/modules/contacts/components/contact-card.tsx create mode 100644 src/modules/contacts/components/contact-form.tsx create mode 100644 src/modules/contacts/components/delete-contact.tsx create mode 100644 src/modules/contacts/contacts.route.ts diff --git a/SESSION.md b/SESSION.md index 006e752..ecd7251 100644 --- a/SESSION.md +++ b/SESSION.md @@ -22,9 +22,36 @@ - `src/db/schema.ts` (updated exports) - `src/drizzle/0001_fantastic_captain_flint.sql` (migration) -## Phase 3: Contacts Module ⏸️ +## Phase 3: Contacts Module πŸ”„ +**Type**: UI + Server Actions | **Started**: 2025-11-08 **Spec**: `docs/IMPLEMENTATION_PHASES.md#phase-3` +**Progress**: +- [ ] Create `src/modules/contacts/actions/` directory +- [ ] Create create-contact.action.ts +- [ ] Create get-contacts.action.ts (with search and tag join) +- [ ] Create update-contact.action.ts +- [ ] Create delete-contact.action.ts +- [ ] Create tag-management.actions.ts (createTag, getTags, assignTag, removeTag) +- [ ] Create `src/modules/contacts/components/` directory +- [ ] Create contact-form.tsx (React Hook Form + Zod) +- [ ] Create contact-card.tsx (display contact with tags) +- [ ] Create `src/app/dashboard/contacts/` directory +- [ ] Create page.tsx (contact list with search) +- [ ] Create new/page.tsx (create contact form) +- [ ] Create [id]/edit/page.tsx (edit contact form) +- [ ] Add contacts navigation to dashboard layout +- [ ] Test CRUD operations manually + +**Next Action**: Create src/modules/contacts/actions/ directory and implement create-contact.action.ts following the todos pattern + +**Key Files**: +- `src/modules/contacts/actions/*.ts` (5 action files) +- `src/modules/contacts/components/*.tsx` (2 components) +- `src/app/dashboard/contacts/**/*.tsx` (3 pages) + +**Known Issues**: None + ## Phase 4: Deals Module ⏸️ **Spec**: `docs/IMPLEMENTATION_PHASES.md#phase-4` diff --git a/src/app/dashboard/contacts/[id]/edit/page.tsx b/src/app/dashboard/contacts/[id]/edit/page.tsx new file mode 100644 index 0000000..ded91df --- /dev/null +++ b/src/app/dashboard/contacts/[id]/edit/page.tsx @@ -0,0 +1,42 @@ +import { eq } from "drizzle-orm"; +import { notFound, redirect } from "next/navigation"; +import { getDb } from "@/db"; +import { requireAuth } from "@/modules/auth/utils/auth-utils"; +import { ContactForm } from "@/modules/contacts/components/contact-form"; +import { contacts } from "@/modules/contacts/schemas/contact.schema"; + +export default async function EditContactPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const { id } = await params; + const user = await requireAuth(); + const db = await getDb(); + + const contact = await db.query.contacts.findFirst({ + where: eq(contacts.id, Number.parseInt(id)), + }); + + if (!contact) { + notFound(); + } + + // Verify ownership + if (contact.userId !== Number.parseInt(user.id)) { + redirect("/dashboard/contacts"); + } + + return ( +
+
+

Edit Contact

+

+ Update contact information +

+
+ + +
+ ); +} diff --git a/src/app/dashboard/contacts/new/page.tsx b/src/app/dashboard/contacts/new/page.tsx new file mode 100644 index 0000000..731f3a1 --- /dev/null +++ b/src/app/dashboard/contacts/new/page.tsx @@ -0,0 +1,16 @@ +import { ContactForm } from "@/modules/contacts/components/contact-form"; + +export default function NewContactPage() { + return ( +
+
+

New Contact

+

+ Add a new contact to your CRM +

+
+ + +
+ ); +} diff --git a/src/app/dashboard/contacts/page.tsx b/src/app/dashboard/contacts/page.tsx new file mode 100644 index 0000000..5efe82f --- /dev/null +++ b/src/app/dashboard/contacts/page.tsx @@ -0,0 +1,83 @@ +import { Plus, Search } from "lucide-react"; +import Link from "next/link"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { getContactsAction } from "@/modules/contacts/actions/get-contacts.action"; +import { ContactCard } from "@/modules/contacts/components/contact-card"; +import contactsRoutes from "@/modules/contacts/contacts.route"; + +export default async function ContactsPage({ + searchParams, +}: { + searchParams: Promise<{ search?: string; tag?: string }>; +}) { + const params = await searchParams; + const contacts = await getContactsAction( + params.search, + params.tag ? Number.parseInt(params.tag) : undefined, + ); + + return ( +
+
+
+

Contacts

+

+ Manage your CRM contacts +

+
+ +
+ +
+
+
+ + +
+
+
+ + {contacts.length === 0 ? ( +
+

+ No contacts found +

+

+ {params.search + ? "Try adjusting your search query" + : "Get started by creating your first contact"} +

+ {!params.search && ( + + )} +
+ ) : ( +
+ {contacts.map((contact) => ( + + ))} +
+ )} + +
+ Showing {contacts.length} contact{contacts.length !== 1 ? "s" : ""} +
+
+ ); +} diff --git a/src/components/navigation.tsx b/src/components/navigation.tsx index a6a3b0f..d67c788 100644 --- a/src/components/navigation.tsx +++ b/src/components/navigation.tsx @@ -1,4 +1,4 @@ -import { CheckSquare, Home } from "lucide-react"; +import { CheckSquare, Home, Users } from "lucide-react"; import Link from "next/link"; import { Button } from "@/components/ui/button"; import LogoutButton from "../modules/auth/components/logout-button"; @@ -28,6 +28,12 @@ export function Navigation() { Todos + + + diff --git a/src/modules/contacts/actions/create-contact.action.ts b/src/modules/contacts/actions/create-contact.action.ts new file mode 100644 index 0000000..2c656e7 --- /dev/null +++ b/src/modules/contacts/actions/create-contact.action.ts @@ -0,0 +1,60 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { redirect } from "next/navigation"; +import { getDb } from "@/db"; +import { requireAuth } from "@/modules/auth/utils/auth-utils"; +import { + contacts, + insertContactSchema, +} from "@/modules/contacts/schemas/contact.schema"; +import contactsRoutes from "../contacts.route"; + +export async function createContactAction(formData: FormData) { + try { + const user = await requireAuth(); + + // Extract form fields + const contactData: Record = {}; + for (const [key, value] of formData.entries()) { + if (value && value !== "" && typeof value === "string") { + contactData[key] = value; + } + } + + // Validate the data + const validatedData = insertContactSchema.parse({ + ...contactData, + userId: Number.parseInt(user.id), + }); + + const db = await getDb(); + await db.insert(contacts).values({ + ...validatedData, + userId: Number.parseInt(user.id), + createdAt: Date.now(), + updatedAt: Date.now(), + }); + + revalidatePath(contactsRoutes.list); + redirect(contactsRoutes.list); + } catch (error) { + // Handle Next.js redirect errors - these are not actual errors + if (error instanceof Error && error.message === "NEXT_REDIRECT") { + throw error; // Re-throw redirect errors as-is + } + + console.error("Error creating contact:", error); + + if ( + error instanceof Error && + error.message === "Authentication required" + ) { + throw new Error("Authentication required"); + } + + throw new Error( + error instanceof Error ? error.message : "Failed to create contact", + ); + } +} diff --git a/src/modules/contacts/actions/delete-contact.action.ts b/src/modules/contacts/actions/delete-contact.action.ts new file mode 100644 index 0000000..2daec29 --- /dev/null +++ b/src/modules/contacts/actions/delete-contact.action.ts @@ -0,0 +1,46 @@ +"use server"; + +import { eq } from "drizzle-orm"; +import { revalidatePath } from "next/cache"; +import { getDb } from "@/db"; +import { requireAuth } from "@/modules/auth/utils/auth-utils"; +import { contacts } from "@/modules/contacts/schemas/contact.schema"; + +export async function deleteContactAction(id: number): Promise { + try { + const user = await requireAuth(); + const db = await getDb(); + + // Verify ownership + const existingContact = await db.query.contacts.findFirst({ + where: eq(contacts.id, id), + }); + + if (!existingContact) { + throw new Error("Contact not found"); + } + + if (existingContact.userId !== Number.parseInt(user.id)) { + throw new Error("Forbidden: You do not own this contact"); + } + + // Delete contact (tags will be cascade deleted) + await db.delete(contacts).where(eq(contacts.id, id)); + + revalidatePath("/dashboard/contacts"); + return true; + } catch (error) { + console.error("Error deleting contact:", error); + + if ( + error instanceof Error && + error.message === "Authentication required" + ) { + throw new Error("Authentication required"); + } + + throw new Error( + error instanceof Error ? error.message : "Failed to delete contact", + ); + } +} diff --git a/src/modules/contacts/actions/get-contacts.action.ts b/src/modules/contacts/actions/get-contacts.action.ts new file mode 100644 index 0000000..5d876a1 --- /dev/null +++ b/src/modules/contacts/actions/get-contacts.action.ts @@ -0,0 +1,112 @@ +"use server"; + +import { and, eq, or, sql } from "drizzle-orm"; +import { getDb } from "@/db"; +import { requireAuth } from "@/modules/auth/utils/auth-utils"; +import { + type Contact, + contactTags, + contacts, + contactsToTags, +} from "@/modules/contacts/schemas/contact.schema"; + +export type ContactWithTags = Contact & { + tags: Array<{ id: number; name: string; color: string }>; +}; + +export async function getContactsAction( + searchQuery?: string, + tagId?: number, +): Promise { + try { + const user = await requireAuth(); + const db = await getDb(); + + // Build the where clause + const conditions = [eq(contacts.userId, Number.parseInt(user.id))]; + + // Add search filter if provided + if (searchQuery && searchQuery.trim() !== "") { + const searchPattern = `%${searchQuery}%`; + conditions.push( + or( + sql`${contacts.firstName} LIKE ${searchPattern} COLLATE NOCASE`, + sql`${contacts.lastName} LIKE ${searchPattern} COLLATE NOCASE`, + sql`${contacts.email} LIKE ${searchPattern} COLLATE NOCASE`, + sql`${contacts.company} LIKE ${searchPattern} COLLATE NOCASE`, + )!, + ); + } + + // Fetch contacts with tags using LEFT JOIN + const results = await db + .select({ + id: contacts.id, + firstName: contacts.firstName, + lastName: contacts.lastName, + email: contacts.email, + phone: contacts.phone, + company: contacts.company, + jobTitle: contacts.jobTitle, + notes: contacts.notes, + userId: contacts.userId, + createdAt: contacts.createdAt, + updatedAt: contacts.updatedAt, + tagId: contactTags.id, + tagName: contactTags.name, + tagColor: contactTags.color, + }) + .from(contacts) + .leftJoin(contactsToTags, eq(contacts.id, contactsToTags.contactId)) + .leftJoin(contactTags, eq(contactsToTags.tagId, contactTags.id)) + .where(and(...conditions)) + .orderBy(contacts.createdAt); + + // Group contacts with their tags + const contactsMap = new Map(); + + for (const row of results) { + if (!contactsMap.has(row.id)) { + contactsMap.set(row.id, { + id: row.id, + firstName: row.firstName, + lastName: row.lastName, + email: row.email, + phone: row.phone, + company: row.company, + jobTitle: row.jobTitle, + notes: row.notes, + userId: row.userId, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + tags: [], + }); + } + + // Add tag if exists + if (row.tagId && row.tagName && row.tagColor) { + const contact = contactsMap.get(row.id)!; + contact.tags.push({ + id: row.tagId, + name: row.tagName, + color: row.tagColor, + }); + } + } + + // Convert map to array + let contactsArray = Array.from(contactsMap.values()); + + // Filter by tag if specified + if (tagId) { + contactsArray = contactsArray.filter((contact) => + contact.tags.some((tag) => tag.id === tagId), + ); + } + + return contactsArray; + } catch (error) { + console.error("Error fetching contacts:", error); + return []; + } +} diff --git a/src/modules/contacts/actions/tag-management.actions.ts b/src/modules/contacts/actions/tag-management.actions.ts new file mode 100644 index 0000000..affca5f --- /dev/null +++ b/src/modules/contacts/actions/tag-management.actions.ts @@ -0,0 +1,199 @@ +"use server"; + +import { and, eq } from "drizzle-orm"; +import { revalidatePath } from "next/cache"; +import { getDb } from "@/db"; +import { requireAuth } from "@/modules/auth/utils/auth-utils"; +import { + type ContactTag, + contactTags, + contactsToTags, + insertContactTagSchema, + insertContactToTagSchema, +} from "@/modules/contacts/schemas/contact.schema"; + +/** + * Create a new contact tag + */ +export async function createTagAction(data: { + name: string; + color: string; +}): Promise { + try { + const user = await requireAuth(); + + const validatedData = insertContactTagSchema.parse({ + ...data, + userId: Number.parseInt(user.id), + }); + + const db = await getDb(); + const result = await db + .insert(contactTags) + .values({ + ...validatedData, + userId: Number.parseInt(user.id), + createdAt: Date.now(), + }) + .returning(); + + revalidatePath("/dashboard/contacts"); + return result[0]; + } catch (error) { + console.error("Error creating tag:", error); + throw new Error( + error instanceof Error ? error.message : "Failed to create tag", + ); + } +} + +/** + * Get all tags for the current user + */ +export async function getTagsAction(): Promise { + try { + const user = await requireAuth(); + const db = await getDb(); + + const tags = await db + .select() + .from(contactTags) + .where(eq(contactTags.userId, Number.parseInt(user.id))) + .orderBy(contactTags.name); + + return tags; + } catch (error) { + console.error("Error fetching tags:", error); + return []; + } +} + +/** + * Assign a tag to a contact + */ +export async function assignTagToContactAction( + contactId: number, + tagId: number, +): Promise { + try { + const user = await requireAuth(); + const db = await getDb(); + + // Verify user owns the contact + const contact = await db.query.contacts.findFirst({ + where: eq(contactTags.id, contactId), + }); + + if (!contact || contact.userId !== Number.parseInt(user.id)) { + throw new Error("Forbidden: Contact not found or not owned by user"); + } + + // Verify user owns the tag + const tag = await db.query.contactTags.findFirst({ + where: eq(contactTags.id, tagId), + }); + + if (!tag || tag.userId !== Number.parseInt(user.id)) { + throw new Error("Forbidden: Tag not found or not owned by user"); + } + + const validatedData = insertContactToTagSchema.parse({ + contactId, + tagId, + }); + + // Check if assignment already exists + const existing = await db.query.contactsToTags.findFirst({ + where: and( + eq(contactsToTags.contactId, validatedData.contactId), + eq(contactsToTags.tagId, validatedData.tagId), + ), + }); + + if (existing) { + return true; // Already assigned + } + + await db.insert(contactsToTags).values(validatedData); + + revalidatePath("/dashboard/contacts"); + return true; + } catch (error) { + console.error("Error assigning tag:", error); + throw new Error( + error instanceof Error ? error.message : "Failed to assign tag", + ); + } +} + +/** + * Remove a tag from a contact + */ +export async function removeTagFromContactAction( + contactId: number, + tagId: number, +): Promise { + try { + const user = await requireAuth(); + const db = await getDb(); + + // Verify user owns the contact + const contact = await db.query.contacts.findFirst({ + where: eq(contactTags.id, contactId), + }); + + if (!contact || contact.userId !== Number.parseInt(user.id)) { + throw new Error("Forbidden: Contact not found or not owned by user"); + } + + await db + .delete(contactsToTags) + .where( + and( + eq(contactsToTags.contactId, contactId), + eq(contactsToTags.tagId, tagId), + ), + ); + + revalidatePath("/dashboard/contacts"); + return true; + } catch (error) { + console.error("Error removing tag:", error); + throw new Error( + error instanceof Error ? error.message : "Failed to remove tag", + ); + } +} + +/** + * Delete a tag (will cascade delete all assignments) + */ +export async function deleteTagAction(tagId: number): Promise { + try { + const user = await requireAuth(); + const db = await getDb(); + + // Verify ownership + const existingTag = await db.query.contactTags.findFirst({ + where: eq(contactTags.id, tagId), + }); + + if (!existingTag) { + throw new Error("Tag not found"); + } + + if (existingTag.userId !== Number.parseInt(user.id)) { + throw new Error("Forbidden: You do not own this tag"); + } + + await db.delete(contactTags).where(eq(contactTags.id, tagId)); + + revalidatePath("/dashboard/contacts"); + return true; + } catch (error) { + console.error("Error deleting tag:", error); + throw new Error( + error instanceof Error ? error.message : "Failed to delete tag", + ); + } +} diff --git a/src/modules/contacts/actions/update-contact.action.ts b/src/modules/contacts/actions/update-contact.action.ts new file mode 100644 index 0000000..742c313 --- /dev/null +++ b/src/modules/contacts/actions/update-contact.action.ts @@ -0,0 +1,73 @@ +"use server"; + +import { eq } from "drizzle-orm"; +import { revalidatePath } from "next/cache"; +import { redirect } from "next/navigation"; +import { getDb } from "@/db"; +import { requireAuth } from "@/modules/auth/utils/auth-utils"; +import { + contacts, + updateContactSchema, +} from "@/modules/contacts/schemas/contact.schema"; +import contactsRoutes from "../contacts.route"; + +export async function updateContactAction(id: number, formData: FormData) { + try { + const user = await requireAuth(); + const db = await getDb(); + + // Verify ownership + const existingContact = await db.query.contacts.findFirst({ + where: eq(contacts.id, id), + }); + + if (!existingContact) { + throw new Error("Contact not found"); + } + + if (existingContact.userId !== Number.parseInt(user.id)) { + throw new Error("Forbidden: You do not own this contact"); + } + + // Extract form fields + const contactData: Record = {}; + for (const [key, value] of formData.entries()) { + if (typeof value === "string") { + contactData[key] = value; + } + } + + // Validate the data + const validatedData = updateContactSchema.parse(contactData); + + // Update contact + await db + .update(contacts) + .set({ + ...validatedData, + updatedAt: Date.now(), + }) + .where(eq(contacts.id, id)); + + revalidatePath(contactsRoutes.list); + redirect(contactsRoutes.list); + } catch (error) { + // Handle Next.js redirect errors - these are not actual errors + if (error instanceof Error && error.message === "NEXT_REDIRECT") { + throw error; // Re-throw redirect errors as-is + } + + console.error("Error updating contact:", error); + + if ( + error instanceof Error && + error.message === "Authentication required" + ) { + throw new Error("Authentication required"); + } + + throw new Error( + error instanceof Error ? error.message : "Failed to update contact", + ); + } +} diff --git a/src/modules/contacts/components/contact-card.tsx b/src/modules/contacts/components/contact-card.tsx new file mode 100644 index 0000000..dd051ee --- /dev/null +++ b/src/modules/contacts/components/contact-card.tsx @@ -0,0 +1,104 @@ +import { Building2, Edit, Mail, Phone, User } from "lucide-react"; +import Link from "next/link"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import type { ContactWithTags } from "../actions/get-contacts.action"; +import contactsRoutes from "../contacts.route"; +import { DeleteContact } from "./delete-contact"; + +interface ContactCardProps { + contact: ContactWithTags; +} + +export function ContactCard({ contact }: ContactCardProps) { + const fullName = [contact.firstName, contact.lastName] + .filter(Boolean) + .join(" "); + + return ( + + +
+
+
+ +

+ {fullName || "No name"} +

+
+ {contact.jobTitle && ( +

+ {contact.jobTitle} +

+ )} +
+
+ + +
+
+
+ + {contact.company && ( +
+ + {contact.company} +
+ )} + + {contact.email && ( + + )} + + {contact.phone && ( + + )} + + {contact.notes && ( +

+ {contact.notes} +

+ )} + + {contact.tags.length > 0 && ( +
+ {contact.tags.map((tag) => ( + + {tag.name} + + ))} +
+ )} +
+
+ ); +} diff --git a/src/modules/contacts/components/contact-form.tsx b/src/modules/contacts/components/contact-form.tsx new file mode 100644 index 0000000..ceab975 --- /dev/null +++ b/src/modules/contacts/components/contact-form.tsx @@ -0,0 +1,245 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { useState, useTransition } from "react"; +import { useForm } from "react-hook-form"; +import type { z } from "zod"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import type { Contact } from "@/modules/contacts/schemas/contact.schema"; +import { insertContactSchema } from "@/modules/contacts/schemas/contact.schema"; +import { createContactAction } from "../actions/create-contact.action"; +import { updateContactAction } from "../actions/update-contact.action"; + +interface ContactFormProps { + initialData?: Contact; +} + +type FormData = z.infer; + +export function ContactForm({ initialData }: ContactFormProps) { + const [isPending, startTransition] = useTransition(); + const [error, setError] = useState(null); + + const form = useForm({ + resolver: zodResolver(insertContactSchema), + defaultValues: { + firstName: initialData?.firstName || "", + lastName: initialData?.lastName || "", + email: initialData?.email || "", + phone: initialData?.phone || "", + company: initialData?.company || "", + jobTitle: initialData?.jobTitle || "", + notes: initialData?.notes || "", + }, + }); + + async function onSubmit(data: FormData) { + setError(null); + const formData = new FormData(); + + // Add all form fields to FormData + Object.entries(data).forEach(([key, value]) => { + if (value !== null && value !== undefined) { + formData.append(key, value.toString()); + } + }); + + startTransition(async () => { + try { + if (initialData) { + await updateContactAction(initialData.id, formData); + } else { + await createContactAction(formData); + } + } catch (err) { + setError( + err instanceof Error ? err.message : "Failed to save contact", + ); + } + }); + } + + return ( + + + + {initialData ? "Edit Contact" : "New Contact"} + + + +
+ + {error && ( +
+ {error} +
+ )} + +
+ ( + + First Name + + + + + + )} + /> + + ( + + Last Name + + + + + + )} + /> +
+ + ( + + Email + + + + + + )} + /> + + ( + + Phone + + + + + + )} + /> + +
+ ( + + Company + + + + + + )} + /> + + ( + + Job Title + + + + + + )} + /> +
+ + ( + + Notes + +