Add audit analytics and campaign metrics
This commit is contained in:
134
convex/campaignMetrics.ts
Normal file
134
convex/campaignMetrics.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
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,
|
||||
};
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user