230 lines
5.8 KiB
TypeScript
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,
|
|
},
|
|
}),
|
|
]);
|
|
}
|