Files
webdev-pipeline/convex/outreach.ts

979 lines
28 KiB
TypeScript

import { v } from "convex/values";
import {
DO_NOT_CONTACT_RECHECK_MS,
FOLLOW_UP_DUE_DELAY_MS,
shouldCreateFollowUpDraftAfterSend,
} from "../lib/outreach-follow-up";
import { normalizeListLimit } from "./domain";
import { internalMutation, mutation, query } from "./_generated/server";
import type { Doc, Id } from "./_generated/dataModel";
import type { MutationCtx, QueryCtx } from "./_generated/server";
const strategy = v.union(
v.literal("call_first"),
v.literal("email_first"),
v.literal("defer"),
v.literal("do_not_contact"),
);
const manualSalesStatus = v.union(
v.literal("follow_up_planned"),
v.literal("follow_up_sent"),
v.literal("reply_received"),
v.literal("not_interested"),
v.literal("later"),
v.literal("meeting_scheduled"),
v.literal("proposal_requested"),
v.literal("proposal_sent"),
v.literal("won"),
v.literal("lost"),
v.literal("do_not_pursue"),
);
const REVIEW_JOIN_LIMIT = 4;
const requireOperator = async (ctx: QueryCtx | MutationCtx) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) {
throw new Error("Nicht autorisiert.");
}
return identity;
};
const latestOutreachForLead = async (
ctx: QueryCtx,
leadId: Id<"leads">,
) => {
const rows = await ctx.db
.query("outreachRecords")
.withIndex("by_leadId", (q) => q.eq("leadId", leadId))
.order("desc")
.take(1);
return rows[0] ?? null;
};
const latestAuditForLead = async (ctx: QueryCtx, leadId: Id<"leads">) => {
const rows = await ctx.db
.query("audits")
.withIndex("by_leadId", (q) => q.eq("leadId", leadId))
.order("desc")
.take(1);
return rows[0] ?? null;
};
const loadReviewRow = async (
ctx: QueryCtx,
lead: Doc<"leads">,
reviewOutreach: Doc<"outreachRecords"> | null,
) => {
const latestOutreach = reviewOutreach ?? await latestOutreachForLead(ctx, lead._id);
const audit = latestOutreach?.auditId
? await ctx.db.get(latestOutreach.auditId)
: await latestAuditForLead(ctx, lead._id);
const auditGenerations = audit
? await ctx.db
.query("auditGenerations")
.withIndex("by_auditId", (q) => q.eq("auditId", audit._id))
.order("desc")
.take(REVIEW_JOIN_LIMIT)
: await ctx.db
.query("auditGenerations")
.withIndex("by_leadId", (q) => q.eq("leadId", lead._id))
.order("desc")
.take(REVIEW_JOIN_LIMIT);
const pageSpeedResults = audit
? await ctx.db
.query("pageSpeedResults")
.withIndex("by_auditId", (q) => q.eq("auditId", audit._id))
.order("desc")
.take(REVIEW_JOIN_LIMIT)
: await ctx.db
.query("pageSpeedResults")
.withIndex("by_leadId", (q) => q.eq("leadId", lead._id))
.order("desc")
.take(REVIEW_JOIN_LIMIT);
const crawlPages = await ctx.db
.query("websiteCrawlPages")
.withIndex("by_leadId_and_createdAt", (q) => q.eq("leadId", lead._id))
.order("desc")
.take(REVIEW_JOIN_LIMIT);
const emailCandidates = await ctx.db
.query("websiteEmailCandidates")
.withIndex("by_leadId", (q) => q.eq("leadId", lead._id))
.order("desc")
.take(REVIEW_JOIN_LIMIT);
return {
id: lead._id,
lead: {
id: lead._id,
companyName: lead.companyName,
niche: lead.niche ?? null,
address: lead.address ?? null,
city: lead.city ?? null,
postalCode: lead.postalCode ?? null,
websiteUrl: lead.websiteUrl ?? null,
websiteDomain: lead.websiteDomain ?? null,
email: lead.email ?? null,
normalizedEmail: lead.normalizedEmail ?? null,
phone: lead.phone ?? null,
normalizedPhone: lead.normalizedPhone ?? null,
contactPerson: lead.contactPerson ?? null,
priority: lead.priority,
priorityReason: lead.priorityReason ?? null,
contactStatus: lead.contactStatus,
contactStatusReason: lead.contactStatusReason ?? null,
duplicateStatus: lead.duplicateStatus,
duplicateReason: lead.duplicateReason ?? null,
blacklistStatus: lead.blacklistStatus,
blacklistReason: lead.blacklistReason ?? null,
notes: lead.notes ?? null,
googleMapsUrl: lead.googleMapsUrl ?? null,
googleRating: lead.googleRating ?? null,
googleUserRatingCount: lead.googleUserRatingCount ?? null,
updatedAt: lead.updatedAt,
},
latestOutreach: latestOutreach,
audit: audit,
auditGenerations: auditGenerations.map((generation) => ({
id: generation._id,
stage: generation.stage,
status: generation.status,
modelProfile: generation.modelProfile,
modelId: generation.modelId,
errorSummary: generation.errorSummary ?? null,
finishReason: generation.finishReason ?? null,
parsedJson: generation.parsedJson ?? null,
createdAt: generation.createdAt,
updatedAt: generation.updatedAt,
})),
usedSkills: audit?.usedSkills ?? [],
skillSummaries: audit?.skillSummaries ?? [],
sourceSummaries: {
pageSpeedResults: pageSpeedResults.map((result) => ({
id: result._id,
strategy: result.strategy,
status: result.status,
sourceUrl: result.sourceUrl,
finalUrl: result.finalUrl ?? null,
errorType: result.errorType ?? null,
errorSummary: result.errorSummary ?? null,
normalized: result.normalized ?? null,
fetchedAt: result.fetchedAt,
createdAt: result.createdAt,
})),
crawlPages: crawlPages.map((page) => ({
id: page._id,
sourceUrl: page.sourceUrl,
finalUrl: page.finalUrl,
pageKind: page.pageKind,
title: page.title ?? null,
metaDescription: page.metaDescription ?? null,
headings: page.headings.slice(0, REVIEW_JOIN_LIMIT),
visibleTextExcerpt: page.visibleTextExcerpt ?? null,
hasContactFormSignal: page.hasContactFormSignal,
hasContactCtaSignal: page.hasContactCtaSignal,
createdAt: page.createdAt,
})),
emailCandidates: emailCandidates.map((candidate) => ({
id: candidate._id,
email: candidate.email,
normalizedEmail: candidate.normalizedEmail,
emailSource: candidate.emailSource,
sourceUrl: candidate.sourceUrl,
contactPerson: candidate.contactPerson ?? null,
isBusinessContactAddress: candidate.isBusinessContactAddress,
isGeneric: candidate.isGeneric,
accepted: candidate.accepted,
createdAt: candidate.createdAt,
})),
},
sortAt: Math.max(
lead.updatedAt,
latestOutreach?.updatedAt ?? 0,
audit?.updatedAt ?? 0,
),
};
};
type OutreachRecordInsertArgs = {
leadId: Id<"leads">;
auditId?: Id<"audits">;
strategy: "call_first" | "email_first" | "defer" | "do_not_contact";
phoneScript?: string;
emailSubject?: string;
emailBody?: string;
followUpDraft?: string;
followUpDueAt?: number;
parentOutreachId?: Id<"outreachRecords">;
salesStatus?: "follow_up_planned" | "follow_up_sent";
now: number;
};
const buildOutreachRecordsInsertPayload = (args: OutreachRecordInsertArgs) => {
const payload: {
leadId: Id<"leads">;
auditId?: Id<"audits">;
strategy: "call_first" | "email_first" | "defer" | "do_not_contact";
phoneScript?: string;
emailSubject?: string;
emailBody?: string;
followUpDraft?: string;
followUpDueAt?: number;
parentOutreachId?: Id<"outreachRecords">;
approvalStatus: "draft";
sendStatus: "not_sent";
responseStatus: "none";
salesStatus: "follow_up_planned" | "follow_up_sent";
createdAt: number;
updatedAt: number;
} = {
leadId: args.leadId,
strategy: args.strategy,
approvalStatus: "draft",
sendStatus: "not_sent",
responseStatus: "none",
salesStatus: args.salesStatus ?? "follow_up_planned",
createdAt: args.now,
updatedAt: args.now,
};
if (args.auditId !== undefined) {
payload.auditId = args.auditId;
}
if (args.phoneScript !== undefined) {
payload.phoneScript = args.phoneScript;
}
if (args.emailSubject !== undefined) {
payload.emailSubject = args.emailSubject;
}
if (args.emailBody !== undefined) {
payload.emailBody = args.emailBody;
}
if (args.followUpDraft !== undefined) {
payload.followUpDraft = args.followUpDraft;
}
if (args.followUpDueAt !== undefined) {
payload.followUpDueAt = args.followUpDueAt;
}
if (args.parentOutreachId !== undefined) {
payload.parentOutreachId = args.parentOutreachId;
}
return payload;
};
async function createFollowUpDraftAfterInitialSend(
ctx: MutationCtx,
outreach: Doc<"outreachRecords">,
sentAt: number,
) {
const existingFollowUps = await ctx.db
.query("outreachRecords")
.withIndex("by_parentOutreachId", (q) => q.eq("parentOutreachId", outreach._id))
.take(1);
if (
!shouldCreateFollowUpDraftAfterSend({
existingFollowUpOutreachCount: existingFollowUps.length,
followUpDraft: outreach.followUpDraft,
salesStatus: outreach.salesStatus,
sendStatus: "sent",
})
) {
return null;
}
return await ctx.db.insert(
"outreachRecords",
buildOutreachRecordsInsertPayload({
leadId: outreach.leadId,
auditId: outreach.auditId,
strategy: "email_first",
emailSubject: outreach.emailSubject
? `Kurze Nachfrage: ${outreach.emailSubject}`
: "Kurze Nachfrage zum Website-Audit",
emailBody: outreach.followUpDraft,
followUpDraft: outreach.followUpDraft,
followUpDueAt: sentAt + FOLLOW_UP_DUE_DELAY_MS,
parentOutreachId: outreach._id,
now: Date.now(),
}),
);
}
export const create = mutation({
args: {
leadId: v.id("leads"),
auditId: v.optional(v.id("audits")),
strategy,
phoneScript: v.optional(v.string()),
emailSubject: v.optional(v.string()),
emailBody: v.optional(v.string()),
followUpDraft: v.optional(v.string()),
},
handler: async (ctx, args) => {
await requireOperator(ctx);
const lead = await ctx.db.get(args.leadId);
if (!lead) {
throw new Error("Lead wurde nicht gefunden.");
}
if (args.auditId) {
const audit = await ctx.db.get(args.auditId);
if (!audit) {
throw new Error("Audit wurde nicht gefunden.");
}
if (audit.leadId !== args.leadId) {
throw new Error("Audit gehoert nicht zu diesem Lead.");
}
}
const now = Date.now();
return await ctx.db.insert(
"outreachRecords",
buildOutreachRecordsInsertPayload({
leadId: args.leadId,
auditId: args.auditId,
strategy: args.strategy,
phoneScript: args.phoneScript,
emailSubject: args.emailSubject,
emailBody: args.emailBody,
followUpDraft: args.followUpDraft,
now,
}),
);
},
});
export const upsertFromAuditGeneration = internalMutation({
args: {
leadId: v.id("leads"),
auditId: v.optional(v.id("audits")),
strategy: strategy,
phoneScript: v.optional(v.string()),
emailSubject: v.optional(v.string()),
emailBody: v.optional(v.string()),
followUpDraft: v.optional(v.string()),
},
handler: async (ctx, args) => {
const now = Date.now();
const lead = await ctx.db.get(args.leadId);
if (!lead) {
throw new Error("Lead wurde nicht gefunden.");
}
if (args.auditId) {
const audit = await ctx.db.get(args.auditId);
if (!audit) {
throw new Error("Audit wurde nicht gefunden.");
}
if (audit.leadId !== args.leadId) {
throw new Error("Audit gehoert nicht zu diesem Lead.");
}
}
const existing = await ctx.db
.query("outreachRecords")
.withIndex("by_leadId", (q) => q.eq("leadId", args.leadId))
.order("desc")
.take(1);
if (existing.length > 0) {
const current = existing[0]!;
if (current.sendStatus === "sent") {
return await ctx.db.insert(
"outreachRecords",
buildOutreachRecordsInsertPayload({
leadId: args.leadId,
auditId: args.auditId,
strategy: args.strategy,
phoneScript: args.phoneScript,
emailSubject: args.emailSubject,
emailBody: args.emailBody,
followUpDraft: args.followUpDraft,
now,
}),
);
}
await ctx.db.patch(current._id, {
...(args.auditId !== undefined ? { auditId: args.auditId } : {}),
strategy: args.strategy,
...(args.phoneScript !== undefined ? { phoneScript: args.phoneScript } : {}),
...(args.emailSubject !== undefined
? { emailSubject: args.emailSubject }
: {}),
...(args.emailBody !== undefined ? { emailBody: args.emailBody } : {}),
...(args.followUpDraft !== undefined
? { followUpDraft: args.followUpDraft }
: {}),
approvalStatus: "draft",
updatedAt: now,
});
return current._id;
}
return await ctx.db.insert(
"outreachRecords",
buildOutreachRecordsInsertPayload({
leadId: args.leadId,
auditId: args.auditId,
strategy: args.strategy,
phoneScript: args.phoneScript,
emailSubject: args.emailSubject,
emailBody: args.emailBody,
followUpDraft: args.followUpDraft,
now,
}),
);
},
});
export const listReviewWorkspace = query({
args: {
limit: v.optional(v.number()),
},
handler: async (ctx, args) => {
await requireOperator(ctx);
const limit = normalizeListLimit(args.limit);
const candidateLimit = Math.min(limit * 10, 300);
const outreachReadyLeads = await ctx.db
.query("leads")
.withIndex("by_contactStatus_and_updatedAt", (q) =>
q.eq("contactStatus", "outreach_ready"),
)
.order("desc")
.take(candidateLimit);
const draftNotSentOutreach = await ctx.db
.query("outreachRecords")
.withIndex("by_approvalStatus_and_sendStatus_and_updatedAt", (q) =>
q.eq("approvalStatus", "draft").eq("sendStatus", "not_sent"),
)
.order("desc")
.take(candidateLimit);
const draftQueuedOutreach = await ctx.db
.query("outreachRecords")
.withIndex("by_approvalStatus_and_sendStatus_and_updatedAt", (q) =>
q.eq("approvalStatus", "draft").eq("sendStatus", "queued"),
)
.order("desc")
.take(candidateLimit);
const draftFailedOutreach = await ctx.db
.query("outreachRecords")
.withIndex("by_approvalStatus_and_sendStatus_and_updatedAt", (q) =>
q.eq("approvalStatus", "draft").eq("sendStatus", "failed"),
)
.order("desc")
.take(candidateLimit);
const approvedNotSentOutreach = await ctx.db
.query("outreachRecords")
.withIndex("by_approvalStatus_and_sendStatus_and_updatedAt", (q) =>
q.eq("approvalStatus", "approved").eq("sendStatus", "not_sent"),
)
.order("desc")
.take(candidateLimit);
const approvedQueuedOutreach = await ctx.db
.query("outreachRecords")
.withIndex("by_approvalStatus_and_sendStatus_and_updatedAt", (q) =>
q.eq("approvalStatus", "approved").eq("sendStatus", "queued"),
)
.order("desc")
.take(candidateLimit);
const approvedFailedOutreach = await ctx.db
.query("outreachRecords")
.withIndex("by_approvalStatus_and_sendStatus_and_updatedAt", (q) =>
q.eq("approvalStatus", "approved").eq("sendStatus", "failed"),
)
.order("desc")
.take(candidateLimit);
const leadCandidates = new Map<
Id<"leads">,
{ lead: Doc<"leads">; outreach: Doc<"outreachRecords"> | null }
>();
for (const lead of outreachReadyLeads) {
leadCandidates.set(lead._id, { lead, outreach: null });
}
const reviewOutreach = [
...draftNotSentOutreach,
...draftQueuedOutreach,
...draftFailedOutreach,
...approvedNotSentOutreach,
...approvedQueuedOutreach,
...approvedFailedOutreach,
]
.filter((outreach) =>
(outreach.approvalStatus === "draft" ||
outreach.approvalStatus === "approved") &&
outreach.sendStatus !== "sent"
)
.sort((a, b) => b.updatedAt - a.updatedAt);
for (const outreach of reviewOutreach) {
const lead = await ctx.db.get(outreach.leadId);
if (!lead) {
continue;
}
const existing = leadCandidates.get(lead._id);
if (!existing || (existing.outreach?.updatedAt ?? 0) < outreach.updatedAt) {
leadCandidates.set(lead._id, { lead, outreach });
}
}
const rows = await Promise.all(
[...leadCandidates.values()].map(({ lead, outreach }) =>
loadReviewRow(ctx, lead, outreach),
),
);
return rows
.sort((a, b) => b.sortAt - a.sortAt)
.slice(0, limit)
.map(({ sortAt, ...row }) => (void sortAt, row));
},
});
export const saveReviewDraft = mutation({
args: {
id: v.id("outreachRecords"),
strategy: strategy,
phoneScript: v.optional(v.string()),
emailSubject: v.optional(v.string()),
emailBody: v.optional(v.string()),
followUpDraft: v.optional(v.string()),
},
handler: async (ctx, args) => {
await requireOperator(ctx);
const outreach = await ctx.db.get(args.id);
if (!outreach) {
throw new Error("Outreach-Datensatz wurde nicht gefunden.");
}
if (outreach.sendStatus === "sent" || outreach.sendStatus === "queued") {
throw new Error("Gesendete Outreach-Datensaetze koennen nicht bearbeitet werden.");
}
const now = Date.now();
await ctx.db.patch(args.id, {
strategy: args.strategy,
...(args.phoneScript !== undefined ? { phoneScript: args.phoneScript } : {}),
...(args.emailSubject !== undefined
? { emailSubject: args.emailSubject }
: {}),
...(args.emailBody !== undefined ? { emailBody: args.emailBody } : {}),
...(args.followUpDraft !== undefined
? { followUpDraft: args.followUpDraft }
: {}),
approvalStatus: "draft",
updatedAt: now,
});
return { id: args.id, approvalStatus: "draft", updatedAt: now };
},
});
export const approveEmailDraft = mutation({
args: {
id: v.id("outreachRecords"),
},
handler: async (ctx, args) => {
await requireOperator(ctx);
const outreach = await ctx.db.get(args.id);
if (!outreach) {
throw new Error("Outreach-Datensatz wurde nicht gefunden.");
}
if (outreach.sendStatus === "sent") {
throw new Error("Gesendete Outreach-Datensaetze koennen nicht freigegeben werden.");
}
if (outreach.sendStatus === "queued") {
throw new Error("Ausstehend freigegebene Outreach-Datensaetze koennen nicht erneut freigegeben werden.");
}
const lead = await ctx.db.get(outreach.leadId);
if (!lead) {
throw new Error("Lead wurde nicht gefunden.");
}
const recipient = lead.email?.trim();
const subject = outreach.emailSubject?.trim();
const body = outreach.emailBody?.trim();
if (!recipient) {
throw new Error("Empfaenger-E-Mail fehlt.");
}
if (!subject) {
throw new Error("E-Mail-Betreff fehlt.");
}
if (!body) {
throw new Error("E-Mail-Text fehlt.");
}
const audit = outreach.auditId ? await ctx.db.get(outreach.auditId) : null;
const now = Date.now();
await ctx.db.patch(args.id, {
approvalStatus: "approved",
updatedAt: now,
});
const sender = process.env.SMTP_FROM?.trim();
if (!sender) {
throw new Error("SMTP-Absender-Adresse fehlt.");
}
return {
id: args.id,
recipient: recipient,
subject: subject,
sender: sender,
auditSlug: audit?.slug ?? null,
approvalStatus: "approved",
updatedAt: now,
};
},
});
export const claimApprovedEmailForSend = internalMutation({
args: {
id: v.id("outreachRecords"),
},
handler: async (ctx, args) => {
await requireOperator(ctx);
const outreach = await ctx.db.get(args.id);
if (!outreach) {
throw new Error("Outreach-Datensatz wurde nicht gefunden.");
}
if (outreach.approvalStatus !== "approved") {
throw new Error("Nur freigegebene Outreachs können versendet werden.");
}
if (outreach.sendStatus === "sent" || outreach.sendStatus === "queued") {
throw new Error("Outreach ist bereits in Versand-Warteschlange oder gesendet.");
}
const lead = await ctx.db.get(outreach.leadId);
if (!lead) {
throw new Error("Lead wurde nicht gefunden.");
}
const recipient = lead.email?.trim();
const subject = outreach.emailSubject?.trim();
const body = outreach.emailBody?.trim();
const sender = process.env.SMTP_FROM?.trim();
if (!recipient) {
throw new Error("Empfaenger-E-Mail fehlt.");
}
if (!subject) {
throw new Error("E-Mail-Betreff fehlt.");
}
if (!body) {
throw new Error("E-Mail-Text fehlt.");
}
if (!sender) {
throw new Error("SMTP-Absender-Adresse fehlt.");
}
const audit = outreach.auditId ? await ctx.db.get(outreach.auditId) : null;
const now = Date.now();
await ctx.db.patch(args.id, {
sendStatus: "queued",
updatedAt: now,
});
return {
outreachId: outreach._id,
id: outreach._id,
leadId: outreach.leadId,
auditId: outreach.auditId,
recipient,
subject,
body,
sender,
auditLink: audit?.slug ? `/audit/${audit.slug}` : null,
};
},
});
const outreachSendAttemptSuccessStatus = "success" as const;
const outreachSendAttemptFailedStatus = "failed" as const;
export const recordEmailSendSuccess = internalMutation({
args: {
id: v.id("outreachRecords"),
recipient: v.string(),
subject: v.string(),
body: v.string(),
sender: v.string(),
auditId: v.optional(v.id("audits")),
auditLink: v.optional(v.union(v.string(), v.null())),
sentAt: v.number(),
smtpMessageId: v.optional(v.string()),
smtpResponse: v.optional(v.string()),
smtpAccepted: v.optional(v.array(v.string())),
smtpRejected: v.optional(v.array(v.string())),
},
handler: async (ctx, args) => {
await requireOperator(ctx);
const outreach = await ctx.db.get(args.id);
if (!outreach) {
throw new Error("Outreach-Datensatz wurde nicht gefunden.");
}
const lead = await ctx.db.get(outreach.leadId);
if (!lead) {
throw new Error("Lead wurde nicht gefunden.");
}
const now = Date.now();
await ctx.db.patch(args.id, {
sendStatus: "sent",
sentAt: args.sentAt,
...(outreach.parentOutreachId ? { salesStatus: "follow_up_sent" as const } : {}),
updatedAt: now,
});
await ctx.db.patch(lead._id, {
contactStatus: "contacted",
updatedAt: now,
});
const attempt: {
outreachId: Id<"outreachRecords">;
leadId: Id<"leads">;
recipient: string;
subject: string;
body: string;
sender: string;
status: typeof outreachSendAttemptSuccessStatus;
sentAt: number;
createdAt: number;
updatedAt: number;
auditId?: Id<"audits">;
auditLink?: string | null;
smtpMessageId?: string;
smtpResponse?: string;
smtpAccepted?: string[];
smtpRejected?: string[];
} = {
outreachId: args.id,
leadId: outreach.leadId,
recipient: args.recipient,
subject: args.subject,
body: args.body,
sender: args.sender,
status: outreachSendAttemptSuccessStatus,
sentAt: args.sentAt,
createdAt: now,
updatedAt: now,
};
if (args.auditId !== undefined) {
attempt.auditId = args.auditId;
}
if (args.auditLink !== undefined) {
attempt.auditLink = args.auditLink;
}
if (args.smtpMessageId !== undefined) {
attempt.smtpMessageId = args.smtpMessageId;
}
if (args.smtpResponse !== undefined) {
attempt.smtpResponse = args.smtpResponse;
}
if (args.smtpAccepted !== undefined) {
attempt.smtpAccepted = args.smtpAccepted;
}
if (args.smtpRejected !== undefined) {
attempt.smtpRejected = args.smtpRejected;
}
await ctx.db.insert("outreachSendAttempts", attempt);
await createFollowUpDraftAfterInitialSend(ctx, outreach, args.sentAt);
},
});
export const updateManualSalesStatus = mutation({
args: {
id: v.id("outreachRecords"),
salesStatus: manualSalesStatus,
},
handler: async (ctx, args) => {
await requireOperator(ctx);
const outreach = await ctx.db.get(args.id);
if (!outreach) {
throw new Error("Outreach-Datensatz wurde nicht gefunden.");
}
const now = Date.now();
const outreachPatch: {
salesStatus: typeof args.salesStatus;
responseStatus?: "none" | "manual_reply_recorded" | "no_interest" | "follow_up_needed";
doNotContactUntil?: number;
updatedAt: number;
} = {
salesStatus: args.salesStatus,
updatedAt: now,
};
const leadPatch: {
contactStatus?: "contacted" | "replied" | "do_not_contact";
updatedAt: number;
} = {
updatedAt: now,
};
if (args.salesStatus === "reply_received") {
outreachPatch.responseStatus = "manual_reply_recorded";
leadPatch.contactStatus = "replied";
}
if (args.salesStatus === "not_interested") {
outreachPatch.responseStatus = "no_interest";
leadPatch.contactStatus = "contacted";
}
if (args.salesStatus === "do_not_pursue") {
outreachPatch.responseStatus = "no_interest";
outreachPatch.doNotContactUntil = now + DO_NOT_CONTACT_RECHECK_MS;
leadPatch.contactStatus = "do_not_contact";
}
await ctx.db.patch(args.id, outreachPatch);
await ctx.db.patch(outreach.leadId, leadPatch);
return {
id: args.id,
salesStatus: args.salesStatus,
doNotContactUntil: outreachPatch.doNotContactUntil ?? null,
};
},
});
export const recordEmailSendFailure = internalMutation({
args: {
id: v.id("outreachRecords"),
recipient: v.string(),
subject: v.string(),
body: v.string(),
sender: v.string(),
auditId: v.optional(v.id("audits")),
auditLink: v.optional(v.union(v.string(), v.null())),
errorMessage: v.optional(v.string()),
errorCode: v.optional(v.string()),
errorResponseCode: v.optional(v.number()),
errorResponse: v.optional(v.string()),
},
handler: async (ctx, args) => {
await requireOperator(ctx);
const outreach = await ctx.db.get(args.id);
if (!outreach) {
throw new Error("Outreach-Datensatz wurde nicht gefunden.");
}
const now = Date.now();
await ctx.db.patch(args.id, {
sendStatus: "failed",
updatedAt: now,
});
const attempt: {
outreachId: Id<"outreachRecords">;
leadId: Id<"leads">;
recipient: string;
subject: string;
body: string;
sender: string;
status: typeof outreachSendAttemptFailedStatus;
createdAt: number;
updatedAt: number;
auditId?: Id<"audits">;
auditLink?: string | null;
errorMessage?: string;
errorCode?: string;
errorResponseCode?: number;
errorResponse?: string;
} = {
outreachId: args.id,
leadId: outreach.leadId,
recipient: args.recipient,
subject: args.subject,
body: args.body,
sender: args.sender,
status: outreachSendAttemptFailedStatus,
createdAt: now,
updatedAt: now,
};
if (args.auditId !== undefined) {
attempt.auditId = args.auditId;
}
if (args.auditLink !== undefined) {
attempt.auditLink = args.auditLink;
}
if (args.errorMessage !== undefined) {
attempt.errorMessage = args.errorMessage;
}
if (args.errorCode !== undefined) {
attempt.errorCode = args.errorCode;
}
if (args.errorResponseCode !== undefined) {
attempt.errorResponseCode = args.errorResponseCode;
}
if (args.errorResponse !== undefined) {
attempt.errorResponse = args.errorResponse;
}
await ctx.db.insert("outreachSendAttempts", attempt);
},
});
export const list = query({
args: {
leadId: v.optional(v.id("leads")),
approvalStatus: v.optional(
v.union(v.literal("draft"), v.literal("approved"), v.literal("rejected")),
),
limit: v.optional(v.number()),
},
handler: async (ctx, args) => {
await requireOperator(ctx);
const limit = normalizeListLimit(args.limit);
if (args.leadId) {
const leadId = args.leadId;
return await ctx.db
.query("outreachRecords")
.withIndex("by_leadId", (q) => q.eq("leadId", leadId))
.order("desc")
.take(limit);
}
if (args.approvalStatus) {
const approvalStatus = args.approvalStatus;
return await ctx.db
.query("outreachRecords")
.withIndex("by_approvalStatus", (q) =>
q.eq("approvalStatus", approvalStatus),
)
.order("desc")
.take(limit);
}
return await ctx.db.query("outreachRecords").order("desc").take(limit);
},
});