495 lines
12 KiB
TypeScript
495 lines
12 KiB
TypeScript
import { internal } from "./_generated/api";
|
|
import type { Doc, Id } from "./_generated/dataModel";
|
|
import {
|
|
internalMutation,
|
|
mutation,
|
|
query,
|
|
} from "./_generated/server";
|
|
import type { MutationCtx, QueryCtx } from "./_generated/server";
|
|
import { v } from "convex/values";
|
|
|
|
const PAGE_SPEED_COUNTER_TEMPLATE = {
|
|
leadsFound: 1,
|
|
leadsCreated: 0,
|
|
auditsCreated: 1,
|
|
outreachPrepared: 0,
|
|
errors: 0,
|
|
};
|
|
|
|
type PageSpeedLead = Pick<
|
|
Doc<"leads">,
|
|
"_id" | "contactStatus"
|
|
> & {
|
|
websiteUrl: string;
|
|
};
|
|
type AuditStartState = {
|
|
leadId: Id<"leads">;
|
|
canStart: boolean;
|
|
reason?: string;
|
|
activeRunId?: Id<"agentRuns">;
|
|
activeRunStatus?: Doc<"agentRuns">["status"];
|
|
};
|
|
|
|
const runStatus = v.union(
|
|
v.literal("pending"),
|
|
v.literal("running"),
|
|
v.literal("succeeded"),
|
|
v.literal("failed"),
|
|
v.literal("canceled"),
|
|
);
|
|
const pageSpeedStrategy = v.union(v.literal("mobile"), v.literal("desktop"));
|
|
const pageSpeedResultStatus = v.union(
|
|
v.literal("succeeded"),
|
|
v.literal("failed"),
|
|
);
|
|
const pageSpeedErrorType = v.union(
|
|
v.literal("quota"),
|
|
v.literal("timeout"),
|
|
v.literal("unavailable"),
|
|
v.literal("invalid_url"),
|
|
v.literal("api_error"),
|
|
v.literal("unknown"),
|
|
);
|
|
|
|
const requireOperator = async (ctx: MutationCtx | QueryCtx) => {
|
|
const identity = await ctx.auth.getUserIdentity();
|
|
if (!identity) {
|
|
throw new Error("Nicht autorisiert.");
|
|
}
|
|
};
|
|
|
|
async function getActivePageSpeedAuditRun(
|
|
ctx: MutationCtx | QueryCtx,
|
|
leadId: Id<"leads">,
|
|
) {
|
|
const existingPending = await ctx.db
|
|
.query("agentRuns")
|
|
.withIndex("by_type_and_status_and_leadId", (q) =>
|
|
q.eq("type", "audit").eq("status", "pending").eq("leadId", leadId),
|
|
)
|
|
.take(1);
|
|
|
|
if (existingPending[0]) {
|
|
return existingPending[0];
|
|
}
|
|
|
|
const existingRunning = await ctx.db
|
|
.query("agentRuns")
|
|
.withIndex("by_type_and_status_and_leadId", (q) =>
|
|
q.eq("type", "audit").eq("status", "running").eq("leadId", leadId),
|
|
)
|
|
.take(1);
|
|
|
|
return existingRunning[0] ?? null;
|
|
}
|
|
|
|
async function getActiveAuditGenerationRun(
|
|
ctx: MutationCtx | QueryCtx,
|
|
leadId: Id<"leads">,
|
|
) {
|
|
const existingPending = await ctx.db
|
|
.query("agentRuns")
|
|
.withIndex("by_type_and_status_and_leadId", (q) =>
|
|
q
|
|
.eq("type", "audit_generation")
|
|
.eq("status", "pending")
|
|
.eq("leadId", leadId),
|
|
)
|
|
.take(1);
|
|
|
|
if (existingPending[0]) {
|
|
return existingPending[0];
|
|
}
|
|
|
|
const existingRunning = await ctx.db
|
|
.query("agentRuns")
|
|
.withIndex("by_type_and_status_and_leadId", (q) =>
|
|
q
|
|
.eq("type", "audit_generation")
|
|
.eq("status", "running")
|
|
.eq("leadId", leadId),
|
|
)
|
|
.take(1);
|
|
|
|
return existingRunning[0] ?? null;
|
|
}
|
|
|
|
async function getLeadAuditStartState(
|
|
ctx: MutationCtx | QueryCtx,
|
|
leadId: Id<"leads">,
|
|
): Promise<AuditStartState> {
|
|
const lead = await ctx.db.get(leadId);
|
|
|
|
if (!lead) {
|
|
return {
|
|
leadId,
|
|
canStart: false,
|
|
reason: "Lead nicht gefunden.",
|
|
};
|
|
}
|
|
|
|
if (
|
|
lead.priority === "blocked" ||
|
|
lead.priority === "defer" ||
|
|
lead.blacklistStatus === "blocked" ||
|
|
lead.contactStatus === "do_not_contact"
|
|
) {
|
|
return {
|
|
leadId,
|
|
canStart: false,
|
|
reason: "Lead ist gesperrt oder zurueckgestellt.",
|
|
};
|
|
}
|
|
|
|
if (!lead.websiteUrl) {
|
|
return {
|
|
leadId,
|
|
canStart: false,
|
|
reason: "Keine Website hinterlegt.",
|
|
};
|
|
}
|
|
|
|
const activeAuditRun =
|
|
(await getActivePageSpeedAuditRun(ctx, leadId)) ??
|
|
(await getActiveAuditGenerationRun(ctx, leadId));
|
|
|
|
if (activeAuditRun) {
|
|
return {
|
|
leadId,
|
|
canStart: false,
|
|
reason: "Audit laeuft bereits.",
|
|
activeRunId: activeAuditRun._id,
|
|
activeRunStatus: activeAuditRun.status,
|
|
};
|
|
}
|
|
|
|
return {
|
|
leadId,
|
|
canStart: true,
|
|
};
|
|
}
|
|
|
|
async function queueLeadPageSpeedAuditForLead(
|
|
ctx: MutationCtx,
|
|
args: {
|
|
leadId: Id<"leads">;
|
|
parentRunId?: Id<"agentRuns">;
|
|
triggeredBy: "internal" | "manual";
|
|
},
|
|
): Promise<Id<"agentRuns"> | null> {
|
|
const state = await getLeadAuditStartState(ctx, args.leadId);
|
|
|
|
if (!state.canStart) {
|
|
return state.activeRunId ?? null;
|
|
}
|
|
|
|
const now = Date.now();
|
|
|
|
const runId = await ctx.db.insert("agentRuns", {
|
|
type: "audit",
|
|
leadId: args.leadId,
|
|
status: "pending",
|
|
currentStep: "pagespeed_insights",
|
|
counters: PAGE_SPEED_COUNTER_TEMPLATE,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
});
|
|
|
|
await ctx.db.insert("agentRunEvents", {
|
|
runId,
|
|
level: "info",
|
|
message:
|
|
args.triggeredBy === "manual"
|
|
? "Audit-Start wurde manuell angefordert."
|
|
: "PageSpeed-Analyse wurde in die Warteschlange gesetzt.",
|
|
details: [
|
|
{ label: "Lead", value: args.leadId },
|
|
...(args.parentRunId ? [{ label: "Parent-Run", value: args.parentRunId }] : []),
|
|
],
|
|
createdAt: now,
|
|
});
|
|
|
|
await ctx.scheduler.runAfter(
|
|
0,
|
|
internal.pageSpeedAction.processPageSpeedAudit,
|
|
{
|
|
runId,
|
|
},
|
|
);
|
|
|
|
return runId;
|
|
}
|
|
|
|
export const getLeadAuditStartStates = query({
|
|
args: {
|
|
leadIds: v.array(v.id("leads")),
|
|
},
|
|
returns: v.array(
|
|
v.object({
|
|
leadId: v.id("leads"),
|
|
canStart: v.boolean(),
|
|
reason: v.optional(v.string()),
|
|
activeRunId: v.optional(v.id("agentRuns")),
|
|
activeRunStatus: v.optional(runStatus),
|
|
}),
|
|
),
|
|
handler: async (ctx, args): Promise<AuditStartState[]> => {
|
|
await requireOperator(ctx);
|
|
|
|
const states: AuditStartState[] = [];
|
|
for (const leadId of args.leadIds.slice(0, 120)) {
|
|
states.push(await getLeadAuditStartState(ctx, leadId));
|
|
}
|
|
|
|
return states;
|
|
},
|
|
});
|
|
|
|
export const requestLeadAudit = mutation({
|
|
args: {
|
|
leadId: v.id("leads"),
|
|
},
|
|
returns: v.object({
|
|
runId: v.union(v.id("agentRuns"), v.null()),
|
|
message: v.string(),
|
|
}),
|
|
handler: async (ctx, args): Promise<{ runId: Id<"agentRuns"> | null; message: string }> => {
|
|
await requireOperator(ctx);
|
|
|
|
const state = await getLeadAuditStartState(ctx, args.leadId);
|
|
if (!state.canStart) {
|
|
return {
|
|
runId: state.activeRunId ?? null,
|
|
message: state.reason ?? "Audit kann aktuell nicht gestartet werden.",
|
|
};
|
|
}
|
|
|
|
const runId = await queueLeadPageSpeedAuditForLead(ctx, {
|
|
leadId: args.leadId,
|
|
triggeredBy: "manual",
|
|
});
|
|
|
|
return {
|
|
runId,
|
|
message: "Audit-Start wurde manuell angefordert.",
|
|
};
|
|
},
|
|
});
|
|
|
|
export const queueLeadPageSpeedAudit = internalMutation({
|
|
args: {
|
|
leadId: v.id("leads"),
|
|
parentRunId: v.optional(v.id("agentRuns")),
|
|
},
|
|
returns: v.union(v.id("agentRuns"), v.null()),
|
|
handler: async (ctx, args): Promise<Id<"agentRuns"> | null> => {
|
|
return await queueLeadPageSpeedAuditForLead(ctx, {
|
|
leadId: args.leadId,
|
|
parentRunId: args.parentRunId,
|
|
triggeredBy: "internal",
|
|
});
|
|
},
|
|
});
|
|
|
|
export const startPageSpeedAuditRun = internalMutation({
|
|
args: {
|
|
runId: v.id("agentRuns"),
|
|
},
|
|
returns: v.union(
|
|
v.object({
|
|
lead: v.object({
|
|
_id: v.id("leads"),
|
|
websiteUrl: v.string(),
|
|
contactStatus: 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"),
|
|
),
|
|
}),
|
|
auditId: v.optional(v.id("audits")),
|
|
}),
|
|
v.null(),
|
|
),
|
|
handler: async (ctx, args): Promise<
|
|
{ lead: PageSpeedLead; auditId?: Id<"audits"> } | null
|
|
> => {
|
|
const now = Date.now();
|
|
const run = await ctx.db.get(args.runId);
|
|
|
|
if (!run) {
|
|
return null;
|
|
}
|
|
|
|
if (run.type !== "audit") {
|
|
return null;
|
|
}
|
|
|
|
if (run.status !== "pending") {
|
|
return null;
|
|
}
|
|
|
|
if (!run.leadId) {
|
|
await ctx.db.patch(args.runId, {
|
|
status: "failed",
|
|
currentStep: "pagespeed_insights",
|
|
errorSummary: "Run hat keine Lead-ID.",
|
|
updatedAt: now,
|
|
finishedAt: now,
|
|
});
|
|
|
|
await ctx.db.insert("agentRunEvents", {
|
|
runId: args.runId,
|
|
level: "error",
|
|
message:
|
|
"PageSpeed-Analyse konnte nicht gestartet werden: Kein Lead verknüpft.",
|
|
details: [{ label: "Lead-ID", value: "unbekannt" }],
|
|
createdAt: now,
|
|
});
|
|
|
|
return null;
|
|
}
|
|
|
|
const lead = await ctx.db.get(run.leadId);
|
|
if (!lead) {
|
|
await ctx.db.patch(args.runId, {
|
|
status: "failed",
|
|
currentStep: "pagespeed_insights",
|
|
errorSummary: "Lead wurde nicht gefunden.",
|
|
updatedAt: now,
|
|
finishedAt: now,
|
|
});
|
|
|
|
await ctx.db.insert("agentRunEvents", {
|
|
runId: args.runId,
|
|
level: "error",
|
|
message:
|
|
"PageSpeed-Analyse konnte nicht gestartet werden: Kein Lead mit Website-URL.",
|
|
details: [{ label: "Lead-ID", value: run.leadId }],
|
|
createdAt: now,
|
|
});
|
|
|
|
return null;
|
|
}
|
|
|
|
if (!lead.websiteUrl) {
|
|
await ctx.db.patch(args.runId, {
|
|
status: "failed",
|
|
currentStep: "pagespeed_insights",
|
|
errorSummary: "Lead hat keine Website-URL.",
|
|
updatedAt: now,
|
|
finishedAt: now,
|
|
});
|
|
|
|
await ctx.db.insert("agentRunEvents", {
|
|
runId: args.runId,
|
|
level: "error",
|
|
message:
|
|
"PageSpeed-Analyse konnte nicht gestartet werden: Kein Lead mit Website-URL.",
|
|
details: [{ label: "Lead-ID", value: lead._id }],
|
|
createdAt: now,
|
|
});
|
|
|
|
return null;
|
|
}
|
|
|
|
await ctx.db.patch(args.runId, {
|
|
status: "running",
|
|
currentStep: "pagespeed_insights",
|
|
startedAt: now,
|
|
updatedAt: now,
|
|
errorSummary: undefined,
|
|
});
|
|
|
|
await ctx.db.insert("agentRunEvents", {
|
|
runId: args.runId,
|
|
level: "info",
|
|
message: "PageSpeed-Analyse gestartet.",
|
|
details: [{ label: "Lead-ID", value: lead._id }],
|
|
createdAt: now,
|
|
});
|
|
|
|
return {
|
|
lead: {
|
|
_id: lead._id,
|
|
websiteUrl: lead.websiteUrl,
|
|
contactStatus: lead.contactStatus,
|
|
},
|
|
...(run.auditId ? { auditId: run.auditId } : {}),
|
|
};
|
|
},
|
|
});
|
|
|
|
export const persistPageSpeedResult = internalMutation({
|
|
args: {
|
|
leadId: v.id("leads"),
|
|
auditId: v.optional(v.id("audits")),
|
|
runId: v.id("agentRuns"),
|
|
strategy: pageSpeedStrategy,
|
|
status: pageSpeedResultStatus,
|
|
sourceUrl: v.string(),
|
|
finalUrl: v.optional(v.string()),
|
|
rawStorageId: v.optional(v.id("_storage")),
|
|
errorType: v.optional(pageSpeedErrorType),
|
|
errorSummary: v.optional(v.string()),
|
|
fetchedAt: v.number(),
|
|
normalized: v.optional(
|
|
v.object({
|
|
scores: v.optional(
|
|
v.object({
|
|
performance: v.optional(v.number()),
|
|
accessibility: v.optional(v.number()),
|
|
bestPractices: v.optional(v.number()),
|
|
seo: v.optional(v.number()),
|
|
}),
|
|
),
|
|
metrics: v.optional(
|
|
v.object({
|
|
firstContentfulPaintMs: v.optional(v.number()),
|
|
largestContentfulPaintMs: v.optional(v.number()),
|
|
cumulativeLayoutShift: v.optional(v.number()),
|
|
totalBlockingTimeMs: v.optional(v.number()),
|
|
speedIndexMs: v.optional(v.number()),
|
|
}),
|
|
),
|
|
opportunities: v.optional(v.array(v.string())),
|
|
implications: v.optional(v.array(v.string())),
|
|
}),
|
|
),
|
|
},
|
|
returns: v.id("pageSpeedResults"),
|
|
handler: async (ctx, args): Promise<Id<"pageSpeedResults">> => {
|
|
return await ctx.db.insert("pageSpeedResults", {
|
|
...args,
|
|
createdAt: Date.now(),
|
|
});
|
|
},
|
|
});
|
|
|
|
export const finishPageSpeedAuditRun = internalMutation({
|
|
args: {
|
|
runId: v.id("agentRuns"),
|
|
status: runStatus,
|
|
errorSummary: v.optional(v.string()),
|
|
errors: v.optional(v.number()),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const now = Date.now();
|
|
|
|
await ctx.db.patch(args.runId, {
|
|
status: args.status,
|
|
updatedAt: now,
|
|
finishedAt: now,
|
|
currentStep: "pagespeed_insights",
|
|
errorSummary: args.errorSummary,
|
|
counters: {
|
|
...PAGE_SPEED_COUNTER_TEMPLATE,
|
|
errors: args.errors ?? 0,
|
|
},
|
|
});
|
|
},
|
|
});
|