Compare commits
1 Commits
main
...
0eccfe3a4d
| Author | SHA1 | Date | |
|---|---|---|---|
| 0eccfe3a4d |
@@ -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");
|
||||
+64
-2
@@ -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 }) => ({
|
||||
@@ -554,3 +612,7 @@ export type Quote = typeof quotes.$inferSelect;
|
||||
export type NewQuote = typeof quotes.$inferInsert;
|
||||
export type Lead = typeof leads.$inferSelect;
|
||||
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;
|
||||
@@ -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<string, any> = { 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));
|
||||
}
|
||||
@@ -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<typeof createLeadSchema>;
|
||||
export type UpdateLeadInput = z.infer<typeof updateLeadSchema>;
|
||||
export type CreateActivityInput = z.infer<typeof createActivitySchema>;
|
||||
export type CreateReminderInput = z.infer<typeof createReminderSchema>;
|
||||
export type UpdateLeadStageInput = z.infer<typeof updateLeadStageSchema>;
|
||||
Reference in New Issue
Block a user