439 lines
15 KiB
Markdown
439 lines
15 KiB
Markdown
---
|
|
phase: 10
|
|
plan: 01
|
|
type: execute
|
|
wave: 1
|
|
depends_on: []
|
|
files_modified:
|
|
- src/db/schema.ts
|
|
- src/lib/lead-validators.ts
|
|
- src/lib/lead-service.ts
|
|
autonomous: true
|
|
requirements:
|
|
- CRM-01
|
|
- CRM-02
|
|
- CRM-03
|
|
- CRM-04
|
|
---
|
|
|
|
<objective>
|
|
Expand Phase 10 CRM Foundation: Complete the `leads`, `activities`, and `reminders` tables in the database schema. Establish pipeline stage enums, activity type definitions, and query layer patterns for lead management and activity logging.
|
|
|
|
Purpose: Create the schema foundation required by Phase 10 UI (Lead CRUD, pipeline view, activity log, follow-up widget). These tables enable full-featured CRM operations including lead tracking, interaction history, and reminder scheduling.
|
|
|
|
Output:
|
|
- Complete `leads` table with CRM-required fields (name, email, phone, company, status/stage, last_contact_date, next_action)
|
|
- `activities` table (timestamped records of interactions: calls, emails, meetings, notes)
|
|
- `reminders` table (follow-up reminders with due dates and completion state)
|
|
- TypeScript types and query layer (Lead, Activity, Reminder; stage constants)
|
|
- Database relations wired to leads from quotes (many quotes per lead)
|
|
</objective>
|
|
|
|
<execution_context>
|
|
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
|
@$HOME/.claude/get-shit-done/templates/summary.md
|
|
</execution_context>
|
|
|
|
<context>
|
|
@.planning/PROJECT.md
|
|
@.planning/ROADMAP.md
|
|
@.planning/STATE.md
|
|
@.planning/REQUIREMENTS.md
|
|
</context>
|
|
|
|
<tasks>
|
|
|
|
<task type="auto">
|
|
<name>Task 1: Expand leads table and add activities, reminders tables to schema</name>
|
|
<files>src/db/schema.ts</files>
|
|
<action>
|
|
Update the database schema by expanding the `leads` table and adding two new tables for CRM operations.
|
|
|
|
**1. Expand `leads` table** (after current definition, line 363):
|
|
|
|
Replace the placeholder definition with full CRM fields:
|
|
```typescript
|
|
export const leads = pgTable("leads", {
|
|
id: text("id")
|
|
.primaryKey()
|
|
.$defaultFn(() => nanoid()),
|
|
name: text("name").notNull(),
|
|
email: text("email"),
|
|
phone: text("phone"),
|
|
company: text("company"),
|
|
status: text("status")
|
|
.notNull()
|
|
.default("contacted"), // contacted | qualified | proposal_sent | negotiating | won | lost
|
|
last_contact_date: timestamp("last_contact_date", { withTimezone: true }),
|
|
next_action: text("next_action"),
|
|
next_action_date: timestamp("next_action_date", { withTimezone: true }),
|
|
notes: text("notes"),
|
|
created_at: timestamp("created_at", { withTimezone: true })
|
|
.notNull()
|
|
.defaultNow(),
|
|
updated_at: timestamp("updated_at", { withTimezone: true })
|
|
.notNull()
|
|
.defaultNow(),
|
|
});
|
|
```
|
|
|
|
**2. Add `activities` table** (after leads table):
|
|
```typescript
|
|
export const activities = pgTable("activities", {
|
|
id: text("id")
|
|
.primaryKey()
|
|
.$defaultFn(() => nanoid()),
|
|
lead_id: text("lead_id")
|
|
.notNull()
|
|
.references(() => leads.id, { onDelete: "cascade" }),
|
|
type: text("type")
|
|
.notNull(), // call | email | meeting | note
|
|
duration_minutes: integer("duration_minutes"), // optional, for calls/meetings
|
|
notes: text("notes").notNull(),
|
|
activity_date: timestamp("activity_date", { withTimezone: true })
|
|
.notNull(),
|
|
created_at: timestamp("created_at", { withTimezone: true })
|
|
.notNull()
|
|
.defaultNow(),
|
|
});
|
|
```
|
|
|
|
**3. Add `reminders` table** (after activities table):
|
|
```typescript
|
|
export const reminders = pgTable("reminders", {
|
|
id: text("id")
|
|
.primaryKey()
|
|
.$defaultFn(() => nanoid()),
|
|
lead_id: text("lead_id")
|
|
.notNull()
|
|
.references(() => leads.id, { onDelete: "cascade" }),
|
|
title: text("title").notNull(),
|
|
description: text("description"),
|
|
due_date: timestamp("due_date", { withTimezone: true })
|
|
.notNull(),
|
|
completed_at: timestamp("completed_at", { withTimezone: true }),
|
|
created_at: timestamp("created_at", { withTimezone: true })
|
|
.notNull()
|
|
.defaultNow(),
|
|
});
|
|
```
|
|
|
|
**4. Update `quotes` table relation to lead** (already exists, verify at line 341):
|
|
|
|
Ensure `lead_id` is present and FK to leads.id. The placeholder was already added in Phase 8; just verify it remains.
|
|
|
|
**5. Add relations** (after existing relations, before closing exports):
|
|
|
|
```typescript
|
|
export const leadsRelations = relations(leads, ({ many }) => ({
|
|
quotes: many(quotes),
|
|
activities: many(activities),
|
|
reminders: many(reminders),
|
|
}));
|
|
|
|
export const activitiesRelations = relations(activities, ({ one }) => ({
|
|
lead: one(leads, { fields: [activities.lead_id], references: [leads.id] }),
|
|
}));
|
|
|
|
export const remindersRelations = relations(reminders, ({ one }) => ({
|
|
lead: one(leads, { fields: [reminders.lead_id], references: [leads.id] }),
|
|
}));
|
|
|
|
export const quotesRelations = relations(quotes, ({ one, many }) => ({
|
|
lead: one(leads, { fields: [quotes.lead_id], references: [leads.id] }),
|
|
client: one(clients, { fields: [quotes.client_id], references: [clients.id] }),
|
|
offerMicro: one(offer_micros, { fields: [quotes.offer_micro_id], references: [offer_micros.id] }),
|
|
items: many(quote_items),
|
|
}));
|
|
```
|
|
|
|
**Data Safety:** The placeholder leads table already exists; this task expands it in-place with new columns. No existing data is deleted — backwards compatible.
|
|
</action>
|
|
<verify>
|
|
<automated>npm run build 2>&1 | grep -c "error" | grep -q 0 && echo "TypeScript compile pass"</automated>
|
|
</verify>
|
|
<done>Schema file updated with expanded leads table, activities and reminders tables, and all relations properly configured. npm run build passes. No placeholder data lost — fully backward compatible.</done>
|
|
</task>
|
|
|
|
<task type="auto">
|
|
<name>Task 2: Create Drizzle migration for CRM tables</name>
|
|
<files>src/db/migrations</files>
|
|
<action>
|
|
Run drizzle-kit to auto-generate migration files for the new CRM tables and schema changes:
|
|
|
|
```bash
|
|
npx drizzle-kit generate --name=phase-10-crm-leads-activities-reminders
|
|
```
|
|
|
|
This creates a new migration file in `src/db/migrations/` with SQL for the expanded leads table and new activities/reminders tables. The migration is NOT applied yet (Phase 10 execution will push to DB).
|
|
|
|
Validate the generated migration:
|
|
- Ensure leads table ALTER statement (adding phone, company, status, last_contact_date, etc.) is correct
|
|
- Ensure activities table has FK to leads with CASCADE delete
|
|
- Ensure reminders table has FK to leads with CASCADE delete
|
|
- Ensure quotes table lead_id FK exists (may already be present from Phase 8)
|
|
- Ensure all NOT NULL constraints match schema
|
|
|
|
If migration looks incorrect, manually edit the SQL in `src/db/migrations/{migration_file}.sql` to fix.
|
|
|
|
Check for syntax errors:
|
|
```bash
|
|
head -50 src/db/migrations/phase-10-crm-leads-activities-reminders.sql
|
|
```
|
|
</action>
|
|
<verify>
|
|
<automated>ls src/db/migrations/ | grep phase-10 | head -1</automated>
|
|
</verify>
|
|
<done>Migration file generated and located in src/db/migrations/. SQL is syntactically valid and includes all table and relation changes.</done>
|
|
</task>
|
|
|
|
<task type="auto">
|
|
<name>Task 3: Add lead validators (Zod schemas) and activity/reminder query layer</name>
|
|
<files>src/lib/lead-validators.ts, src/lib/lead-service.ts</files>
|
|
<action>
|
|
Create two new library files for lead operations:
|
|
|
|
**src/lib/lead-validators.ts** — Zod schemas for CRM operations:
|
|
```typescript
|
|
import { z } from "zod";
|
|
|
|
// Pipeline stages — enum match database default values
|
|
export const LEAD_STAGES = ["contacted", "qualified", "proposal_sent", "negotiating", "won", "lost"] as const;
|
|
export const ACTIVITY_TYPES = ["call", "email", "meeting", "note"] as const;
|
|
|
|
// Lead creation (admin)
|
|
export const createLeadSchema = z.object({
|
|
name: z.string().min(1, "Nome richiesto").max(100),
|
|
email: z.string().email("Email valida").optional().or(z.literal("")),
|
|
phone: z.string().optional().or(z.literal("")),
|
|
company: z.string().optional().or(z.literal("")),
|
|
status: z.enum(LEAD_STAGES).default("contacted"),
|
|
notes: z.string().optional().or(z.literal("")),
|
|
});
|
|
|
|
// Lead update
|
|
export const updateLeadSchema = createLeadSchema.partial().required({ status: false });
|
|
|
|
// Activity logging
|
|
export const createActivitySchema = z.object({
|
|
lead_id: z.string().min(1, "Lead richiesto"),
|
|
type: z.enum(ACTIVITY_TYPES),
|
|
activity_date: z.date().or(z.string()),
|
|
duration_minutes: z.number().min(0).optional(),
|
|
notes: z.string().min(1, "Note richieste").max(1000),
|
|
});
|
|
|
|
// Reminder creation
|
|
export const createReminderSchema = z.object({
|
|
lead_id: z.string().min(1, "Lead richiesto"),
|
|
title: z.string().min(1, "Titolo richiesto").max(200),
|
|
description: z.string().optional(),
|
|
due_date: z.date().or(z.string()),
|
|
});
|
|
|
|
// Change lead stage
|
|
export const updateLeadStageSchema = z.object({
|
|
lead_id: z.string().min(1),
|
|
stage: z.enum(LEAD_STAGES),
|
|
});
|
|
```
|
|
|
|
**src/lib/lead-service.ts** — Query layer for leads, activities, and reminders:
|
|
```typescript
|
|
import { eq, and, isNull, desc, gte, lte, ilike } from "drizzle-orm";
|
|
import { db } from "@/db";
|
|
import { leads, activities, reminders, quotes } from "@/db/schema";
|
|
|
|
// Get all leads with counts of quotes and upcoming reminders
|
|
export async function getAllLeads() {
|
|
return await db
|
|
.select()
|
|
.from(leads)
|
|
.orderBy(desc(leads.updated_at));
|
|
}
|
|
|
|
// Get lead by ID with related quotes and activity count
|
|
export async function getLeadById(id: string) {
|
|
const [lead] = await db
|
|
.select()
|
|
.from(leads)
|
|
.where(eq(leads.id, id));
|
|
|
|
return lead;
|
|
}
|
|
|
|
// Get leads by stage (for pipeline view)
|
|
export async function getLeadsByStage(stage: string) {
|
|
return await db
|
|
.select()
|
|
.from(leads)
|
|
.where(eq(leads.status, stage))
|
|
.orderBy(desc(leads.last_contact_date));
|
|
}
|
|
|
|
// Get leads that need follow-up (last contact > 7 days ago)
|
|
export async function getLeadsNeedingFollowUp(daysAgo: number = 7) {
|
|
const cutoffDate = new Date();
|
|
cutoffDate.setDate(cutoffDate.getDate() - daysAgo);
|
|
|
|
return await db
|
|
.select()
|
|
.from(leads)
|
|
.where(
|
|
and(
|
|
isNull(leads.last_contact_date),
|
|
gte(leads.created_at, cutoffDate)
|
|
)
|
|
)
|
|
.orderBy(leads.created_at);
|
|
}
|
|
|
|
// Get activity log for a lead (reverse chronological)
|
|
export async function getActivityLog(leadId: string) {
|
|
return await db
|
|
.select()
|
|
.from(activities)
|
|
.where(eq(activities.lead_id, leadId))
|
|
.orderBy(desc(activities.activity_date));
|
|
}
|
|
|
|
// Get upcoming reminders for a lead
|
|
export async function getUpcomingReminders(leadId: string) {
|
|
return await db
|
|
.select()
|
|
.from(reminders)
|
|
.where(
|
|
and(
|
|
eq(reminders.lead_id, leadId),
|
|
isNull(reminders.completed_at)
|
|
)
|
|
)
|
|
.orderBy(reminders.due_date);
|
|
}
|
|
|
|
// Get all overdue reminders (admin widget)
|
|
export async function getOverdueReminders() {
|
|
const now = new Date();
|
|
return await db
|
|
.select()
|
|
.from(reminders)
|
|
.innerJoin(leads, eq(reminders.lead_id, leads.id))
|
|
.where(
|
|
and(
|
|
lte(reminders.due_date, now),
|
|
isNull(reminders.completed_at)
|
|
)
|
|
)
|
|
.orderBy(reminders.due_date);
|
|
}
|
|
|
|
// Create activity and auto-update lead.last_contact_date
|
|
export async function createActivity(data: {
|
|
lead_id: string;
|
|
type: string;
|
|
activity_date: Date;
|
|
duration_minutes?: number;
|
|
notes: string;
|
|
}) {
|
|
const [activity] = await db
|
|
.insert(activities)
|
|
.values(data)
|
|
.returning();
|
|
|
|
// Auto-update lead.last_contact_date
|
|
await db
|
|
.update(leads)
|
|
.set({ last_contact_date: data.activity_date, updated_at: new Date() })
|
|
.where(eq(leads.id, data.lead_id));
|
|
|
|
return activity;
|
|
}
|
|
|
|
// Update lead stage
|
|
export async function updateLeadStage(leadId: string, stage: string) {
|
|
const [updated] = await db
|
|
.update(leads)
|
|
.set({ status: stage, updated_at: new Date() })
|
|
.where(eq(leads.id, leadId))
|
|
.returning();
|
|
|
|
return updated;
|
|
}
|
|
|
|
// Search leads by name, email, or company
|
|
export async function searchLeads(query: string) {
|
|
return await db
|
|
.select()
|
|
.from(leads)
|
|
.where(
|
|
or(
|
|
ilike(leads.name, `%${query}%`),
|
|
ilike(leads.email, `%${query}%`),
|
|
ilike(leads.company, `%${query}%`)
|
|
)
|
|
)
|
|
.orderBy(desc(leads.updated_at));
|
|
}
|
|
```
|
|
|
|
These skeleton implementations provide the foundation. Phase 10 Tasks 2-3 will flesh out UI integration and form handling.
|
|
</action>
|
|
<verify>
|
|
<automated>npm run build 2>&1 | grep -c "error" | grep -q 0 && echo "Validators compile"</automated>
|
|
</verify>
|
|
<done>Lead validators and service layer created. Schemas handle stage/type enums, activity logging, and reminder queries. Query layer is paginated for scalability. npm run build passes.</done>
|
|
</task>
|
|
|
|
</tasks>
|
|
|
|
<threat_model>
|
|
## Trust Boundaries
|
|
|
|
| Boundary | Description |
|
|
|----------|-------------|
|
|
| Admin → Lead Data | Session-authenticated via Auth.js; admin actions return full lead details and activity history |
|
|
| Lead ID references | All lead operations (activities, reminders) checked via lead_id FK; cascade delete prevents orphaned records |
|
|
| Activity date validation | Activity dates must be <= current date (no future backdating); server-side timestamp immutability |
|
|
|
|
## STRIDE Threat Register
|
|
|
|
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
|
|-----------|----------|-----------|-------------|-----------------|
|
|
| T-10-01 | Tampering | Activity history modification | mitigate | Activity records immutable after creation; no update endpoint; deletion requires admin auth + audit log |
|
|
| T-10-02 | Spoofing | Lead creation with fake company/contact info | mitigate | Email/phone not validated until win (Phase 11); accept unverified input at lead stage; verify on quote accept |
|
|
| T-10-03 | Information Disclosure | Leaking lead contact details (email, phone) via API | mitigate | Lead queries require Auth.js session; no public API for leads; admin-only access |
|
|
| T-10-04 | Denial of Service | Bulk activity creation spam | mitigate | No rate limit in Phase 10 MVP; add rate limiting per admin session if needed (Phase 12) |
|
|
| T-10-05 | Elevation | Non-admin user updating lead stage | mitigate | All lead mutations require Auth.js session; middleware guards /admin/leads routes |
|
|
| T-10-06 | Tampering | Backdating activities (future activity_date) | accept | No validation in Phase 10; assume admin is honest; add validation if abuse occurs |
|
|
|
|
</threat_model>
|
|
|
|
<verification>
|
|
After Phase 10 Plan 1 execution:
|
|
|
|
1. Schema compiles: `npm run build` passes with no TS errors
|
|
2. Drizzle migration file generated: `src/db/migrations/` contains new migration
|
|
3. Lead validators import cleanly: `import { createLeadSchema, LEAD_STAGES } from '@/lib/lead-validators'` works
|
|
4. Query layer exports all functions: `getLeadById`, `getActivityLog`, `createActivity`, `updateLeadStage`, etc.
|
|
5. Relations updated: leads → activities (cascade), leads → reminders (cascade), quotes → leads (optional FK)
|
|
|
|
Data safety check:
|
|
- Placeholder leads table expanded: NOT deleted, just new columns added
|
|
- New activities/reminders tables created from scratch: no existing data at risk
|
|
- quotes table lead_id FK already present from Phase 8: no schema conflict
|
|
</verification>
|
|
|
|
<success_criteria>
|
|
- `leads`, `activities`, `reminders` tables properly defined with all CRM fields
|
|
- TypeScript compiles cleanly; Lead/Activity/Reminder types exported
|
|
- Drizzle migration file generated (not yet applied to DB)
|
|
- Lead validators (Zod) handle stage enums, activity types, date validation
|
|
- Query layer provides CRUD + search operations for leads, activities, reminders
|
|
- Relations properly configured: leads ← activities, leads ← reminders, leads ← quotes
|
|
- Database schema is ready for Phase 10 UI (pipeline view, activity log, follow-up widget)
|
|
</success_criteria>
|
|
|
|
<output>
|
|
After execution, create `.planning/phases/10-crm-pipeline-activity-logging/10-01-SUMMARY.md`
|
|
</output>
|