Files
pitchfast/convex/campaignMetrics.ts

135 lines
4.9 KiB
TypeScript

import { v } from "convex/values";
import { normalizeListLimit } from "./domain";
import { query } from "./_generated/server";
const priority = v.union(
v.literal("high"),
v.literal("medium"),
v.literal("low"),
v.literal("defer"),
v.literal("blocked"),
);
const leadStatus = v.union(
v.literal("new"),
v.literal("missing_contact"),
v.literal("audit_ready"),
v.literal("outreach_ready"),
v.literal("contacted"),
v.literal("replied"),
v.literal("do_not_contact"),
);
export const getDashboard = query({
args: {
campaignId: v.optional(v.id("campaigns")),
niche: v.optional(v.string()),
postalCode: v.optional(v.string()),
radiusKm: v.optional(v.number()),
priority: v.optional(priority),
status: v.optional(leadStatus),
from: v.optional(v.number()),
to: v.optional(v.number()),
limit: v.optional(v.number()),
},
handler: async (ctx, args) => {
const limit = normalizeListLimit(args.limit);
const campaigns = await ctx.db.query("campaigns").order("desc").take(100);
const leads = await ctx.db.query("leads").order("desc").take(500);
const audits = await ctx.db.query("audits").order("desc").take(500);
const outreach = await ctx.db.query("outreachRecords").order("desc").take(500);
const runs = await ctx.db
.query("agentRuns")
.withIndex("by_type", (q) => q.eq("type", "campaign"))
.order("desc")
.take(100);
const filteredLeads = leads.filter((lead) => {
const campaign = lead.campaignId
? campaigns.find((row) => row._id === lead.campaignId)
: null;
if (args.campaignId && lead.campaignId !== args.campaignId) {
return false;
}
if (args.niche && lead.niche !== args.niche) {
return false;
}
if (args.postalCode && lead.postalCode !== args.postalCode) {
return false;
}
if (args.radiusKm && campaign?.radiusKm !== args.radiusKm) {
return false;
}
if (args.priority && lead.priority !== args.priority) {
return false;
}
if (args.status && lead.contactStatus !== args.status) {
return false;
}
if (args.from && lead.createdAt < args.from) {
return false;
}
if (args.to && lead.createdAt > args.to) {
return false;
}
return true;
});
const leadIds = new Set(filteredLeads.map((lead) => lead._id));
const filteredAudits = audits.filter((audit) => leadIds.has(audit.leadId));
const filteredOutreach = outreach.filter((row) => leadIds.has(row.leadId));
const runRows = runs.slice(0, limit).map((run) => ({
id: run._id,
campaignId: run.campaignId ?? null,
status: run.status,
newLeads: run.counters?.leadsCreated ?? 0,
skippedDuplicates: 0,
skippedBlacklisted: 0,
errors: run.counters?.errors ?? 0,
auditsGenerated: run.counters?.auditsCreated ?? 0,
updatedAt: run.updatedAt,
errorSummary: run.errorSummary ?? null,
}));
return {
filters: {
campaigns: campaigns.map((campaign) => ({
id: campaign._id,
name: campaign.name,
})),
niches: [...new Set(leads.map((lead) => lead.niche).filter(Boolean))].sort(),
postalCodes: [...new Set(leads.map((lead) => lead.postalCode).filter(Boolean))].sort(),
},
metrics: {
foundLeads: filteredLeads.length,
leadsWithContact: filteredLeads.filter((lead) => Boolean(lead.email || lead.phone)).length,
missingContact: filteredLeads.filter((lead) => lead.contactStatus === "missing_contact").length,
auditsCreated: filteredAudits.length,
approvalsOpen: filteredOutreach.filter((row) => row.approvalStatus === "draft").length,
emailsSent: filteredOutreach.filter((row) => row.sendStatus === "sent").length,
followUpsPlanned: filteredOutreach.filter((row) => row.salesStatus === "follow_up_planned").length,
followUpsSent: filteredOutreach.filter((row) => row.salesStatus === "follow_up_sent").length,
responses: filteredOutreach.filter((row) => row.salesStatus === "reply_received").length,
conversations: filteredOutreach.filter((row) =>
row.salesStatus === "meeting_scheduled" ||
row.salesStatus === "proposal_requested" ||
row.salesStatus === "proposal_sent" ||
row.salesStatus === "won",
).length,
offers: filteredOutreach.filter((row) =>
row.salesStatus === "proposal_requested" ||
row.salesStatus === "proposal_sent",
).length,
wins: filteredOutreach.filter((row) => row.salesStatus === "won").length,
losses: filteredOutreach.filter((row) => row.salesStatus === "lost").length,
skippedDuplicates: runRows.reduce((total, run) => total + run.skippedDuplicates, 0),
skippedBlacklisted: runRows.reduce((total, run) => total + run.skippedBlacklisted, 0),
rybbitAuditOpens: 0,
rybbitCtaClicks: 0,
},
runs: runRows,
};
},
});