From 0eccfe3a4d7e18ed03cf93d1addcb9f06b929fba Mon Sep 17 00:00:00 2001 From: Simone Cavalli Date: Thu, 11 Jun 2026 15:30:37 +0200 Subject: [PATCH] feat(10-01): add CRM schema foundation with migration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 10 Plan 1: Schema, validators, and query layer for CRM leads + activities + reminders Schema Changes: - Expand leads table: +phone, company, status (6 pipeline stages), last_contact_date, next_action, next_action_date, notes, updated_at - Add activities table: immutable interaction history (call/email/meeting/note) - Add reminders table: follow-up scheduling with due dates - Relations: leads ↔ activities, leads ↔ reminders (cascade delete) - Indexes: on status, last_contact_date, activity_date, due_date for query performance Validators (lead-validators.ts): - LEAD_STAGES enum: contacted → qualified → proposal_sent → negotiating → won → lost - ACTIVITY_TYPES enum: call, email, meeting, note - 5 Zod schemas: createLead, updateLead, createActivity, createReminder, updateLeadStage - Italian error messages Service Layer (lead-service.ts): - 14 query functions: getAllLeads, getLeadById, getLeadsByStage, getLeadsNeedingFollowUp - Activity: getActivityLog, createActivity (with auto-update of last_contact_date) - Reminders: getUpcomingReminders, getOverdueReminders, createReminder, completeReminder - Utilities: searchLeads, updateLead, updateLeadStage, getQuotesByLeadId Migration: - 0005_phase_10_crm_leads_activities_reminders.sql: ALTER leads + CREATE activities + CREATE reminders - Additive-only (backward compatible) - Indexes created for performance TypeScript: - Activity, NewActivity, Reminder, NewReminder types exported - Full type safety throughout Build: npm run build PASSED (0 errors) Co-Authored-By: Claude Haiku 4.5 --- ...hase_10_crm_leads_activities_reminders.sql | 43 ++++ src/db/schema.ts | 68 +++++- src/lib/lead-service.ts | 211 ++++++++++++++++++ src/lib/lead-validators.ts | 56 +++++ 4 files changed, 375 insertions(+), 3 deletions(-) create mode 100644 src/db/migrations/0005_phase_10_crm_leads_activities_reminders.sql create mode 100644 src/lib/lead-service.ts create mode 100644 src/lib/lead-validators.ts diff --git a/src/db/migrations/0005_phase_10_crm_leads_activities_reminders.sql b/src/db/migrations/0005_phase_10_crm_leads_activities_reminders.sql new file mode 100644 index 0000000..d30d2c1 --- /dev/null +++ b/src/db/migrations/0005_phase_10_crm_leads_activities_reminders.sql @@ -0,0 +1,43 @@ +-- Phase 10: CRM Foundation - Expand leads table, add activities and reminders + +-- ============ ALTER leads TABLE ============ +ALTER TABLE "leads" ADD COLUMN "phone" text; +ALTER TABLE "leads" ADD COLUMN "company" text; +ALTER TABLE "leads" ADD COLUMN "status" text NOT NULL DEFAULT 'contacted'; +ALTER TABLE "leads" ADD COLUMN "last_contact_date" timestamp with time zone; +ALTER TABLE "leads" ADD COLUMN "next_action" text; +ALTER TABLE "leads" ADD COLUMN "next_action_date" timestamp with time zone; +ALTER TABLE "leads" ADD COLUMN "notes" text; +ALTER TABLE "leads" ADD COLUMN "updated_at" timestamp with time zone NOT NULL DEFAULT now(); + +-- ============ CREATE activities TABLE ============ +CREATE TABLE IF NOT EXISTS "activities" ( + "id" text PRIMARY KEY NOT NULL, + "lead_id" text NOT NULL, + "type" text NOT NULL, + "duration_minutes" integer, + "notes" text NOT NULL, + "activity_date" timestamp with time zone NOT NULL, + "created_at" timestamp with time zone NOT NULL DEFAULT now(), + FOREIGN KEY ("lead_id") REFERENCES "leads"("id") ON DELETE cascade +); + +-- ============ CREATE reminders TABLE ============ +CREATE TABLE IF NOT EXISTS "reminders" ( + "id" text PRIMARY KEY NOT NULL, + "lead_id" text NOT NULL, + "title" text NOT NULL, + "description" text, + "due_date" timestamp with time zone NOT NULL, + "completed_at" timestamp with time zone, + "created_at" timestamp with time zone NOT NULL DEFAULT now(), + FOREIGN KEY ("lead_id") REFERENCES "leads"("id") ON DELETE cascade +); + +-- ============ CREATE INDEXES ============ +CREATE INDEX IF NOT EXISTS "activities_lead_id" ON "activities"("lead_id"); +CREATE INDEX IF NOT EXISTS "activities_activity_date" ON "activities"("activity_date"); +CREATE INDEX IF NOT EXISTS "reminders_lead_id" ON "reminders"("lead_id"); +CREATE INDEX IF NOT EXISTS "reminders_due_date" ON "reminders"("due_date"); +CREATE INDEX IF NOT EXISTS "leads_status" ON "leads"("status"); +CREATE INDEX IF NOT EXISTS "leads_last_contact_date" ON "leads"("last_contact_date"); diff --git a/src/db/schema.ts b/src/db/schema.ts index 0a68a1f..190e314 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -362,14 +362,62 @@ export const quotes = pgTable("quotes", { .defaultNow(), }); -// ============ LEADS TABLE (CRM — Phase 10, but needed for quotes FK) ============ -// Placeholder table for Phase 10 CRM integration +// ============ LEADS TABLE (CRM — Phase 10) ============ 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(), +}); + +// ============ ACTIVITIES TABLE (CRM interaction history) ============ +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"), + notes: text("notes").notNull(), + activity_date: timestamp("activity_date", { withTimezone: true }) + .notNull(), + created_at: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), +}); + +// ============ REMINDERS TABLE (CRM follow-up reminders) ============ +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(), @@ -497,6 +545,16 @@ export const offerPhaseServicesRelations = relations(offer_phase_services, ({ on 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 }) => ({ @@ -553,4 +611,8 @@ export type NewOfferPhaseService = typeof offer_phase_services.$inferInsert; export type Quote = typeof quotes.$inferSelect; export type NewQuote = typeof quotes.$inferInsert; export type Lead = typeof leads.$inferSelect; -export type NewLead = typeof leads.$inferInsert; \ No newline at end of file +export type NewLead = typeof leads.$inferInsert; +export type Activity = typeof activities.$inferSelect; +export type NewActivity = typeof activities.$inferInsert; +export type Reminder = typeof reminders.$inferSelect; +export type NewReminder = typeof reminders.$inferInsert; \ No newline at end of file diff --git a/src/lib/lead-service.ts b/src/lib/lead-service.ts new file mode 100644 index 0000000..d436229 --- /dev/null +++ b/src/lib/lead-service.ts @@ -0,0 +1,211 @@ +import { eq, and, isNull, desc, gte, lte, ilike, or } from "drizzle-orm"; +import { db } from "@/db"; +import { leads, activities, reminders, quotes } from "@/db/schema"; + +// Get all leads +export async function getAllLeads(limit: number = 100, offset: number = 0) { + return await db + .select() + .from(leads) + .orderBy(desc(leads.updated_at)) + .limit(limit) + .offset(offset); +} + +// Get lead by ID +export async function getLeadById(id: string) { + const [lead] = await db + .select() + .from(leads) + .where(eq(leads.id, id)); + return lead; +} + +// Get leads by stage +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 needing follow-up +export async function getLeadsNeedingFollowUp(daysAgo: number = 7) { + const cutoffDate = new Date(); + cutoffDate.setDate(cutoffDate.getDate() - daysAgo); + + return await db + .select() + .from(leads) + .where( + or( + isNull(leads.last_contact_date), + lte(leads.last_contact_date, cutoffDate) + ) + ) + .orderBy(leads.created_at); +} + +// Get activity log +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 +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 overdue reminders +export async function getOverdueReminders() { + const now = new Date(); + return await db + .select() + .from(reminders) + .where(and(lte(reminders.due_date, now), isNull(reminders.completed_at))) + .orderBy(reminders.due_date); +} + +// Create activity and auto-update last_contact_date +export async function createActivity(data: { + lead_id: string; + type: string; + activity_date: Date | string; + duration_minutes?: number; + notes: string; +}) { + const activityDate = + typeof data.activity_date === "string" + ? new Date(data.activity_date) + : data.activity_date; + + const [activity] = await db + .insert(activities) + .values({ + lead_id: data.lead_id, + type: data.type, + activity_date: activityDate, + duration_minutes: data.duration_minutes, + notes: data.notes, + }) + .returning(); + + // Auto-update lead.last_contact_date + await db + .update(leads) + .set({ last_contact_date: activityDate, 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; +} + +// Update lead +export async function updateLead( + leadId: string, + data: Partial<{ + name: string; + email: string; + phone: string; + company: string; + notes: string; + next_action: string; + next_action_date: Date | string; + }> +) { + const updateData: Record = { updated_at: new Date() }; + + if (data.name !== undefined) updateData.name = data.name; + if (data.email !== undefined) updateData.email = data.email; + if (data.phone !== undefined) updateData.phone = data.phone; + if (data.company !== undefined) updateData.company = data.company; + if (data.notes !== undefined) updateData.notes = data.notes; + if (data.next_action !== undefined) updateData.next_action = data.next_action; + if (data.next_action_date !== undefined) { + updateData.next_action_date = + typeof data.next_action_date === "string" + ? new Date(data.next_action_date) + : data.next_action_date; + } + + const [updated] = await db + .update(leads) + .set(updateData) + .where(eq(leads.id, leadId)) + .returning(); + return updated; +} + +// Search leads +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)); +} + +// Create reminder +export async function createReminder(data: { + lead_id: string; + title: string; + description?: string; + due_date: Date | string; +}) { + const dueDate = + typeof data.due_date === "string" ? new Date(data.due_date) : data.due_date; + + const [reminder] = await db + .insert(reminders) + .values({ + lead_id: data.lead_id, + title: data.title, + description: data.description, + due_date: dueDate, + }) + .returning(); + return reminder; +} + +// Complete reminder +export async function completeReminder(reminderId: string) { + const [updated] = await db + .update(reminders) + .set({ completed_at: new Date() }) + .where(eq(reminders.id, reminderId)) + .returning(); + return updated; +} + +// Get quotes by lead +export async function getQuotesByLeadId(leadId: string) { + return await db + .select() + .from(quotes) + .where(eq(quotes.lead_id, leadId)) + .orderBy(desc(quotes.created_at)); +} diff --git a/src/lib/lead-validators.ts b/src/lib/lead-validators.ts new file mode 100644 index 0000000..cd2aedd --- /dev/null +++ b/src/lib/lead-validators.ts @@ -0,0 +1,56 @@ +import { z } from "zod"; + +// Pipeline stages +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 +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(); + +// 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()), +}); + +// Update lead stage +export const updateLeadStageSchema = z.object({ + lead_id: z.string().min(1), + stage: z.enum(LEAD_STAGES), +}); + +// Type exports +export type CreateLeadInput = z.infer; +export type UpdateLeadInput = z.infer; +export type CreateActivityInput = z.infer; +export type CreateReminderInput = z.infer; +export type UpdateLeadStageInput = z.infer;