Add audit analytics and campaign metrics
This commit is contained in:
6
convex/_generated/api.d.ts
vendored
6
convex/_generated/api.d.ts
vendored
@@ -13,7 +13,9 @@ import type * as auditGenerationAction from "../auditGenerationAction.js";
|
||||
import type * as auditInputs from "../auditInputs.js";
|
||||
import type * as audits from "../audits.js";
|
||||
import type * as blacklist from "../blacklist.js";
|
||||
import type * as campaignMetrics from "../campaignMetrics.js";
|
||||
import type * as campaigns from "../campaigns.js";
|
||||
import type * as crons from "../crons.js";
|
||||
import type * as domain from "../domain.js";
|
||||
import type * as http from "../http.js";
|
||||
import type * as leadDiscovery from "../leadDiscovery.js";
|
||||
@@ -23,6 +25,7 @@ import type * as outreachSendAction from "../outreachSendAction.js";
|
||||
import type * as pageSpeed from "../pageSpeed.js";
|
||||
import type * as pageSpeedAction from "../pageSpeedAction.js";
|
||||
import type * as runs from "../runs.js";
|
||||
import type * as scheduledJobs from "../scheduledJobs.js";
|
||||
import type * as settings from "../settings.js";
|
||||
import type * as storage from "../storage.js";
|
||||
import type * as websiteEnrichment from "../websiteEnrichment.js";
|
||||
@@ -40,7 +43,9 @@ declare const fullApi: ApiFromModules<{
|
||||
auditInputs: typeof auditInputs;
|
||||
audits: typeof audits;
|
||||
blacklist: typeof blacklist;
|
||||
campaignMetrics: typeof campaignMetrics;
|
||||
campaigns: typeof campaigns;
|
||||
crons: typeof crons;
|
||||
domain: typeof domain;
|
||||
http: typeof http;
|
||||
leadDiscovery: typeof leadDiscovery;
|
||||
@@ -50,6 +55,7 @@ declare const fullApi: ApiFromModules<{
|
||||
pageSpeed: typeof pageSpeed;
|
||||
pageSpeedAction: typeof pageSpeedAction;
|
||||
runs: typeof runs;
|
||||
scheduledJobs: typeof scheduledJobs;
|
||||
settings: typeof settings;
|
||||
storage: typeof storage;
|
||||
websiteEnrichment: typeof websiteEnrichment;
|
||||
|
||||
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,
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -5,13 +5,13 @@ import { internal } from "./_generated/api";
|
||||
const crons = cronJobs();
|
||||
|
||||
crons.interval(
|
||||
"Kampagnen nach Cadence starten",
|
||||
"campaign cadence runner",
|
||||
{ hours: 1 },
|
||||
internal.scheduledJobs.runDueCampaigns,
|
||||
);
|
||||
|
||||
crons.interval(
|
||||
"Audit-Lifecycle prüfen",
|
||||
"audit lifecycle runner",
|
||||
{ hours: 24 },
|
||||
internal.scheduledJobs.runAuditLifecycle,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user