979 lines
28 KiB
TypeScript
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);
|
|
},
|
|
});
|