Add SMTP send flow for approved outreach
This commit is contained in:
@@ -181,6 +181,62 @@ const loadReviewRow = async (
|
||||
};
|
||||
};
|
||||
|
||||
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;
|
||||
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;
|
||||
approvalStatus: "draft";
|
||||
sendStatus: "not_sent";
|
||||
responseStatus: "none";
|
||||
salesStatus: "follow_up_planned";
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
} = {
|
||||
leadId: args.leadId,
|
||||
strategy: args.strategy,
|
||||
approvalStatus: "draft",
|
||||
sendStatus: "not_sent",
|
||||
responseStatus: "none",
|
||||
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;
|
||||
}
|
||||
|
||||
return payload;
|
||||
};
|
||||
|
||||
export const create = mutation({
|
||||
args: {
|
||||
leadId: v.id("leads"),
|
||||
@@ -210,16 +266,19 @@ export const create = mutation({
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
return await ctx.db.insert("outreachRecords", {
|
||||
...args,
|
||||
approvalStatus: "draft",
|
||||
sendStatus: "not_sent",
|
||||
responseStatus: "none",
|
||||
salesStatus: "follow_up_planned",
|
||||
createdAt: now,
|
||||
updatedAt: 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,
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -260,15 +319,19 @@ export const upsertFromAuditGeneration = internalMutation({
|
||||
if (existing.length > 0) {
|
||||
const current = existing[0]!;
|
||||
if (current.sendStatus === "sent") {
|
||||
return await ctx.db.insert("outreachRecords", {
|
||||
...args,
|
||||
approvalStatus: "draft",
|
||||
sendStatus: "not_sent",
|
||||
responseStatus: "none",
|
||||
salesStatus: "follow_up_planned",
|
||||
createdAt: now,
|
||||
updatedAt: 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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
await ctx.db.patch(current._id, {
|
||||
@@ -289,15 +352,19 @@ export const upsertFromAuditGeneration = internalMutation({
|
||||
return current._id;
|
||||
}
|
||||
|
||||
return await ctx.db.insert("outreachRecords", {
|
||||
...args,
|
||||
approvalStatus: "draft",
|
||||
sendStatus: "not_sent",
|
||||
responseStatus: "none",
|
||||
salesStatus: "follow_up_planned",
|
||||
createdAt: now,
|
||||
updatedAt: 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,
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -425,7 +492,7 @@ export const saveReviewDraft = mutation({
|
||||
if (!outreach) {
|
||||
throw new Error("Outreach-Datensatz wurde nicht gefunden.");
|
||||
}
|
||||
if (outreach.sendStatus === "sent") {
|
||||
if (outreach.sendStatus === "sent" || outreach.sendStatus === "queued") {
|
||||
throw new Error("Gesendete Outreach-Datensaetze koennen nicht bearbeitet werden.");
|
||||
}
|
||||
|
||||
@@ -462,6 +529,9 @@ export const approveEmailDraft = mutation({
|
||||
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) {
|
||||
@@ -487,11 +557,16 @@ export const approveEmailDraft = mutation({
|
||||
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,
|
||||
@@ -499,6 +574,243 @@ export const approveEmailDraft = mutation({
|
||||
},
|
||||
});
|
||||
|
||||
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,
|
||||
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);
|
||||
},
|
||||
});
|
||||
|
||||
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")),
|
||||
|
||||
Reference in New Issue
Block a user