Files
HiveGrid-Wireframes/hivegrid-cloud/apps/web/app/utils/leads/leads.db.server.ts
T

230 lines
5.8 KiB
TypeScript

/**
* Server-side Prisma helpers for the admin lead inbox (Phase 05 #78).
* Keeps loader/action handlers free of raw query plumbing.
*/
import type { LeadStatus } from "@toolctrl/db/prisma-browser";
import { db } from "~/utils/db.server";
export type LeadFilter = {
status?: LeadStatus;
source?: string;
search?: string;
page?: number;
pageSize?: number;
};
export interface LeadListItem {
id: string;
email: string;
firstName: string | null;
lastName: string | null;
company: string | null;
source: string | null;
status: LeadStatus;
utmSource: string | null;
utmCampaign: string | null;
createdAt: Date;
lastTouchAt: Date;
}
export interface LeadListResult {
items: LeadListItem[];
total: number;
page: number;
pageSize: number;
}
const LEAD_EXPORT_MAX = 50_000;
export interface LeadExportRow {
id: string;
email: string;
firstName: string | null;
lastName: string | null;
phone: string | null;
company: string | null;
source: string | null;
status: LeadStatus;
utmSource: string | null;
utmMedium: string | null;
utmCampaign: string | null;
utmTerm: string | null;
utmContent: string | null;
gclid: string | null;
fbclid: string | null;
referrer: string | null;
landingPage: string | null;
score: number;
createdAt: Date;
lastTouchAt: Date;
}
function buildLeadWhere(filter: Pick<LeadFilter, "status" | "source" | "search">): Record<string, unknown> {
const where: Record<string, unknown> = {};
if (filter.status) where.status = filter.status;
if (filter.source) where.source = filter.source;
if (filter.search) {
const q = filter.search.trim();
if (q.length > 0) {
where.OR = [
{ email: { contains: q, mode: "insensitive" } },
{ firstName: { contains: q, mode: "insensitive" } },
{ lastName: { contains: q, mode: "insensitive" } },
{ company: { contains: q, mode: "insensitive" } },
];
}
}
return where;
}
export async function listLeadsForExport(filter: Pick<LeadFilter, "status" | "source" | "search"> = {}): Promise<LeadExportRow[]> {
const where = buildLeadWhere(filter);
return db.lead.findMany({
where,
orderBy: { createdAt: "desc" },
take: LEAD_EXPORT_MAX,
select: {
id: true,
email: true,
firstName: true,
lastName: true,
phone: true,
company: true,
source: true,
status: true,
utmSource: true,
utmMedium: true,
utmCampaign: true,
utmTerm: true,
utmContent: true,
gclid: true,
fbclid: true,
referrer: true,
landingPage: true,
score: true,
createdAt: true,
lastTouchAt: true,
},
});
}
export { LEAD_EXPORT_MAX };
export async function listLeads(filter: LeadFilter = {}): Promise<LeadListResult> {
const page = Math.max(1, filter.page ?? 1);
const pageSize = Math.min(100, Math.max(5, filter.pageSize ?? 25));
const where: Record<string, unknown> = {};
if (filter.status) where.status = filter.status;
if (filter.source) where.source = filter.source;
if (filter.search) {
const q = filter.search.trim();
if (q.length > 0) {
where.OR = [
{ email: { contains: q, mode: "insensitive" } },
{ firstName: { contains: q, mode: "insensitive" } },
{ lastName: { contains: q, mode: "insensitive" } },
{ company: { contains: q, mode: "insensitive" } },
];
}
}
const [items, total] = await Promise.all([
db.lead.findMany({
where,
orderBy: { createdAt: "desc" },
skip: (page - 1) * pageSize,
take: pageSize,
select: {
id: true,
email: true,
firstName: true,
lastName: true,
company: true,
source: true,
status: true,
utmSource: true,
utmCampaign: true,
createdAt: true,
lastTouchAt: true,
},
}),
db.lead.count({ where }),
]);
return { items, total, page, pageSize };
}
export async function getLead(id: string) {
return db.lead.findUnique({
where: { id },
include: {
events: {
orderBy: { createdAt: "desc" },
take: 100,
},
},
});
}
export async function appendLeadEvent(
leadId: string,
type: string,
payload: Record<string, unknown>,
actorId?: string | null,
): Promise<void> {
await db.leadEvent.create({
data: {
leadId,
type,
actorId: actorId ?? null,
payload: payload as object,
},
});
}
export async function updateLeadStatus(
leadId: string,
next: LeadStatus,
actorId: string | null,
): Promise<void> {
const current = await db.lead.findUnique({ where: { id: leadId }, select: { status: true } });
if (!current) return;
if (current.status === next) return;
await db.$transaction([
db.lead.update({ where: { id: leadId }, data: { status: next } }),
db.leadEvent.create({
data: {
leadId,
type: "status-changed",
actorId,
payload: { from: current.status, to: next } as object,
},
}),
]);
}
export async function appendNote(leadId: string, body: string, actorId: string | null): Promise<void> {
const lead = await db.lead.findUnique({ where: { id: leadId }, select: { notes: true } });
if (!lead) return;
const previous = (lead.notes ?? null) as Record<string, unknown> | null;
const updated = {
body,
updatedAt: new Date().toISOString(),
history: [
...(((previous?.history as unknown[]) ?? []).slice(-19)),
{ body: (previous?.body as string | undefined) ?? "", at: (previous?.updatedAt as string | undefined) ?? null },
],
};
await db.$transaction([
db.lead.update({ where: { id: leadId }, data: { notes: updated as object } }),
db.leadEvent.create({
data: {
leadId,
type: "note-added",
actorId,
payload: { length: body.length } as object,
},
}),
]);
}