Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions backend/src/db/newsletterIssues.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,15 @@ export interface NewsletterSlotStats {
recordedAt: string;
}

/** Where the sponsor's emails for one booking go and which Gmail thread they continue. */
export interface SponsorEmailRoute {
to: string;
cc: string[];
sender: string;
/** Gmail thread id of the `email-sponsor` send. */
threadRef: string;
}

export interface NewsletterSlot {
/** The booked sponsor; an open slot has none. Booking adds the slot's Tasks to the Card. */
sponsor?: string;
Expand All @@ -60,6 +69,8 @@ export interface NewsletterSlot {
documentUrl?: string;
/** Performance metrics for this slot's placement and sponsored social posts. */
stats?: NewsletterSlotStats;
/** Set when `email-sponsor` is sent; `newsletter report --send` replies in this thread. */
route?: SponsorEmailRoute;
}

/** A link the issue keeps before (or without) a Card: a draft, a doc, a campaign. */
Expand Down
80 changes: 80 additions & 0 deletions backend/src/newsletter/reportSend.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
/**
* `dataops newsletter report --send`: reply to the sponsor in the Gmail thread
* of the booking's `email-sponsor` send, from Orin's mailbox, then close the
* slot's `send-performance-to-sponsor` Task with the Gmail message id. The
* route comes only from the booking; a missing route stops the send.
*/
import type { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';

import type { NewsletterIssue, NewsletterSlotPosition } from '../db/newsletterIssues';
import { getTaskConsistent, listTasksByCardConsistent, updateTask } from '../db/tasks';
import { deliverRenderedEmail, SPONSOR_DOC_EMAIL_FROM } from '../sponsorDocs/emailDelivery';
import { buildSponsorStatsReport } from './statsReport';
import { requireSponsorRoute, ROUTE_SEND_TASK_REFS, SponsorRouteError } from './sponsorRoute';

export class ReportSendError extends Error {
constructor(readonly statusCode: number, message: string, readonly code: string, readonly detail: Record<string, unknown> = {}) {
super(message);
this.name = 'ReportSendError';
}
}

export async function sendIssueReport(
client: DynamoDBDocumentClient,
issue: NewsletterIssue,
options: { slot: NewsletterSlotPosition; contactName: string; actorId: string },
) {
const { slot: position, contactName, actorId } = options;
const where = `The ${position} slot of ${issue.id}`;
const slot = issue.slots?.[position];
if (!slot?.sponsor) throw new ReportSendError(422, `${where} has no booked sponsor`, 'slot_not_booked');
if (!contactName.trim()) throw new ReportSendError(400, 'contactName is required to send the report', 'contact_required');
const report = buildSponsorStatsReport(issue, position, { contactName });
if (!report.recordedAt) throw new ReportSendError(422, `${where} has no recorded stats; record them before sending the report`, 'stats_missing');

let route;
try {
route = requireSponsorRoute(slot.route, where);
} catch (error) {
if (error instanceof SponsorRouteError) throw new ReportSendError(422, error.message, 'sponsor_route_missing');
throw error;
}
if (route.sender !== SPONSOR_DOC_EMAIL_FROM) {
throw new ReportSendError(422, `${where} route sender is ${route.sender}; reports are sent from ${SPONSOR_DOC_EMAIL_FROM}`, 'sponsor_route_sender');
}
if (!issue.cardId) throw new ReportSendError(422, `${issue.id} has no Card, so there is no send task to close`, 'no_card');
const ref = ROUTE_SEND_TASK_REFS[position][0];
const task = (await listTasksByCardConsistent(client, issue.cardId)).find((item) => item.templateTaskRef === ref);
if (!task) throw new ReportSendError(422, `Card ${issue.cardId} has no ${ref} task`, 'send_task_missing');
if (task.status === 'done') throw new ReportSendError(409, `Task ${task.id} (${ref}) is already done; the report was not sent again`, 'already_sent');

const delivery = await deliverRenderedEmail(client, {
draft: { subject: 'Newsletter performance', body: report.message },
recipientEmail: route.to,
taskId: task.id,
cc: route.cc,
threadId: route.threadRef,
});
if (delivery.state !== 'sent' || !delivery.messageId) {
console.error(JSON.stringify({ level: 'ERROR', message: 'sponsor report not sent', issueId: issue.id, slot: position, reason: delivery.reason, detail: delivery.message }));
throw new ReportSendError(502, delivery.message || `The report was not sent (${delivery.reason || delivery.state})`, delivery.reason || 'not_sent', { emailDelivery: delivery });
}

const current = await getTaskConsistent(client, task.id);
if (!current) throw new ReportSendError(500, `Task ${task.id} vanished after the report was sent (Gmail message ${delivery.messageId})`, 'task_missing', { emailDelivery: delivery });
const now = new Date().toISOString();
const comment = `Sponsor report sent in thread ${route.threadRef} to ${route.to} (Gmail message ID: ${delivery.messageId})`;
try {
await updateTask(client, current.id, {
currentTask: current,
expectedVersion: current.version,
actorId,
triggerKind: 'sponsor-report-sent',
patch: { status: 'done', completedAt: now, completedBy: actorId, comment, externalStatus: `Gmail message ${delivery.messageId}` },
});
} catch (error) {
console.error(JSON.stringify({ level: 'ERROR', message: 'sponsor report sent but task not closed', taskId: task.id, gmailMessageId: delivery.messageId, reason: (error as Error).message }));
throw new ReportSendError(500, `The report was sent (Gmail message ${delivery.messageId}) but Task ${task.id} was not closed: ${(error as Error).message}`, 'task_not_closed', { emailDelivery: delivery });
}
return { emailDelivery: delivery, taskId: task.id, route };
}
132 changes: 132 additions & 0 deletions backend/src/newsletter/sponsorRoute.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
/**
* The sponsor email route of a newsletter slot booking: who the sponsor emails
* go to and which Gmail thread they continue. Sending `email-sponsor` records
* it on the slot and copies it onto the slot's later send Tasks; a missing
* route is an error, never a guess.
*/
import type { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';

import {
listNewsletterIssuesByCardId,
updateNewsletterIssue,
type NewsletterSlotPosition,
type SponsorEmailRoute,
} from '../db/newsletterIssues';
import { getTaskConsistent, listTasksByCardConsistent, TaskVersionConflictError, updateTask } from '../db/tasks';
import type { SponsorDocEmailDelivery } from '../sponsorDocs/emailDelivery';
import type { Task } from '../types';

/** The Task whose send fixes a slot's route. */
export const ROUTE_SOURCE_TASK_REFS: Record<string, NewsletterSlotPosition> = {
'email-sponsor': 'primary',
'secondary-email-sponsor': 'secondary',
};

/** The send Tasks of a slot that reuse its route. */
export const ROUTE_SEND_TASK_REFS: Record<NewsletterSlotPosition, string[]> = {
primary: ['send-performance-to-sponsor', 'send-email-sponsor-publication-live'],
secondary: ['secondary-send-performance-to-sponsor', 'secondary-send-email-sponsor-publication-live'],
};

export class SponsorRouteError extends Error {
constructor(message: string) {
super(message);
this.name = 'SponsorRouteError';
}
}

const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;

export function sponsorRouteFromDelivery(delivery: SponsorDocEmailDelivery): SponsorEmailRoute {
if (delivery.state !== 'sent') throw new SponsorRouteError('The email was not sent; there is no route to record');
if (!delivery.to || !EMAIL.test(delivery.to)) throw new SponsorRouteError('The sent email has no recipient address');
if (!delivery.threadId) throw new SponsorRouteError('Gmail returned no thread id for the sent email');
return { to: delivery.to, cc: delivery.cc || [], sender: delivery.from, threadRef: delivery.threadId };
}

/** The route as stored, or a loud error naming what is missing. */
export function requireSponsorRoute(value: unknown, where: string): SponsorEmailRoute {
const route = value as Partial<SponsorEmailRoute> | null | undefined;
if (!route || typeof route !== 'object') throw new SponsorRouteError(`${where} has no sponsor email route (to, cc, sender, threadRef); send email-sponsor first or backfill it`);
const missing = ['to', 'sender', 'threadRef'].filter((name) => typeof route[name as 'to'] !== 'string' || !route[name as 'to']);
if (missing.length || !Array.isArray(route.cc)) {
throw new SponsorRouteError(`${where} has an incomplete sponsor email route (missing ${[...missing, ...(Array.isArray(route.cc) ? [] : ['cc'])].join(', ')})`);
}
return route as SponsorEmailRoute;
}

async function setTaskRoute(client: DynamoDBDocumentClient, task: Task, route: SponsorEmailRoute): Promise<void> {
let current: Task | null = task;
for (let attempt = 0; attempt < 3 && current; attempt += 1) {
if (JSON.stringify(current.sponsorRoute) === JSON.stringify(route)) return;
try {
await updateTask(client, current.id, {
currentTask: current,
expectedVersion: current.version,
patch: { sponsorRoute: route },
actorId: 'system:sponsor-route',
triggerKind: 'sponsor-route-set',
});
return;
} catch (error) {
if (!(error instanceof TaskVersionConflictError) || attempt === 2) throw error;
current = await getTaskConsistent(client, task.id);
}
}
}

/** Set the slot's route and copy it onto the slot's send Tasks. Returns the Task ids it wrote. */
export async function applySponsorRoute(
client: DynamoDBDocumentClient,
cardId: string,
position: NewsletterSlotPosition,
route: SponsorEmailRoute,
): Promise<string[]> {
const issues = await listNewsletterIssuesByCardId(client, cardId);
if (issues.length !== 1) throw new SponsorRouteError(`Card ${cardId} is linked to ${issues.length} newsletter issues; expected one`);
const issue = issues[0];
const slot = issue.slots?.[position];
if (!slot?.sponsor) throw new SponsorRouteError(`The ${position} slot of ${issue.id} has no booked sponsor`);
if (JSON.stringify(slot.route) !== JSON.stringify(route)) {
await updateNewsletterIssue(client, issue, { slots: { ...issue.slots, [position]: { ...slot, route } } });
}
const refs = new Set(ROUTE_SEND_TASK_REFS[position]);
const written: string[] = [];
for (const task of await listTasksByCardConsistent(client, cardId)) {
if (!task.templateTaskRef || !refs.has(task.templateTaskRef)) continue;
await setTaskRoute(client, task, route);
written.push(task.id);
}
return written;
}

/**
* After `email-sponsor` (or its secondary twin) sent, record the route it
* established. Other Tasks are ignored. Throws SponsorRouteError when the send
* cannot be recorded as a route, after logging a server-side ERROR.
*/
export async function recordSponsorRouteAfterSend(
client: DynamoDBDocumentClient,
taskId: string,
delivery: SponsorDocEmailDelivery,
): Promise<void> {
if (delivery.state !== 'sent') return;
const task = await getTaskConsistent(client, taskId);
const position = task?.templateTaskRef ? ROUTE_SOURCE_TASK_REFS[task.templateTaskRef] : undefined;
if (!task || !position) return;
try {
if (!task.cardId) throw new SponsorRouteError(`Task ${taskId} has no Card`);
await applySponsorRoute(client, task.cardId, position, sponsorRouteFromDelivery(delivery));
} catch (error) {
console.error(JSON.stringify({
level: 'ERROR',
message: 'sponsor email route not recorded after email-sponsor send',
taskId,
gmailMessageId: delivery.messageId,
reason: (error as Error).message,
}));
throw new SponsorRouteError(
`The sponsor email was sent (Gmail message ${delivery.messageId}) but its route was not recorded: ${(error as Error).message}`,
);
}
}
4 changes: 4 additions & 0 deletions backend/src/newsletter/statsReport.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type {
NewsletterIssue,
SponsorEmailRoute,
NewsletterIssueKind,
NewsletterSlotPosition,
} from '../db/newsletterIssues';
Expand All @@ -16,6 +17,8 @@ export interface SponsorStatsReport {
sponsor: string | null;
organizationId?: string;
recordedAt: string | null;
/** Where the sponsor's emails go (To/Cc/sender/Gmail thread); null until `email-sponsor` was sent. */
route: SponsorEmailRoute | null;
newsletter: {
sent?: number;
views?: number;
Expand Down Expand Up @@ -137,6 +140,7 @@ export function buildSponsorStatsReport(
sponsor: slot.sponsor || null,
...(slot.organizationId ? { organizationId: slot.organizationId } : {}),
recordedAt,
route: slot.route || null,
newsletter,
linkedin,
x,
Expand Down
11 changes: 10 additions & 1 deletion backend/src/routes/emailTemplates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
type KnowledgeEmailTemplate,
type RenderedKnowledgeEmail,
} from '../sponsorDocs/knowledgeEmailTemplate';
import { recordSponsorRouteAfterSend, SponsorRouteError } from '../newsletter/sponsorRoute';
import { noStoreJson, parseJsonObjectEventOrEmpty } from '../http';
import type { LambdaEvent, LambdaResponse } from '../types';

Expand Down Expand Up @@ -145,7 +146,15 @@ export async function handleEmailTemplateRoutes(
templateRevision: template.revision,
...(emailDelivery.state === 'sent' ? { taskId } : {}),
};
if (delivery.state === 'sent') await recordEmailOnTask(client, taskId, template.id, delivery);
if (delivery.state === 'sent') {
await recordEmailOnTask(client, taskId, template.id, delivery);
try {
await recordSponsorRouteAfterSend(client, taskId, delivery);
} catch (error) {
if (error instanceof SponsorRouteError) return noStoreJson(500, { error: error.message, code: 'sponsor_route_not_recorded', emailDelivery: delivery });
throw error;
}
}
return noStoreJson(200, {
emailDelivery: delivery,
sender: SPONSOR_DOC_EMAIL_FROM,
Expand Down
54 changes: 54 additions & 0 deletions backend/src/routes/newsletterIssues.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,9 @@ import {
MailchimpMetricsError,
pullIssueMailchimpMetrics,
} from '../newsletter/mailchimpMetrics';
import { GmailMessageRouteError, resolveGmailMessageRoute } from '../sponsorDocs/gmailMessageRoute';
import { applySponsorRoute, SponsorRouteError } from '../newsletter/sponsorRoute';
import { ReportSendError, sendIssueReport } from '../newsletter/reportSend';
import { json, parseJsonObjectEvent } from '../http';

const ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
Expand Down Expand Up @@ -978,6 +981,47 @@ async function issueReportRoute(id: string, event: LambdaEvent, client: DynamoDB
});
}

async function issueReportSendRoute(id: string, body: Body, event: LambdaEvent, client: DynamoDBDocumentClient) {
const resolved = await resolveInteractiveActor(client, event, 'work-write');
if (!resolved.ok) return resolved.response;
const item = await resolveNewsletterIssue(client, id);
if (!item) return json(404, { error: 'Not found' });
const slot = body.slot;
if (slot !== 'primary' && slot !== 'secondary') return json(400, { error: 'slot is required: primary or secondary' });
if (!(SLOT_POSITIONS[item.kind] as readonly string[]).includes(slot)) return json(400, { error: `A ${item.kind} issue has no ${slot} slot` });
try {
const sent = await sendIssueReport(client, item, {
slot,
contactName: typeof body.contactName === 'string' ? body.contactName : '',
actorId: resolved.actor.id,
});
return json(200, sent);
} catch (error) {
if (error instanceof ReportSendError) return json(error.statusCode, { error: error.message, code: error.code, ...error.detail });
throw error;
}
}

/** Adopt an `email-sponsor` message Orin already sent as the booking's route. */
async function slotSponsorRouteRoute(id: string, position: string, body: Body, event: LambdaEvent, client: DynamoDBDocumentClient) {
const resolved = await resolveInteractiveActor(client, event, 'work-write');
if (!resolved.ok) return resolved.response;
const item = await resolveNewsletterIssue(client, id);
if (!item) return json(404, { error: 'Not found' });
if (position !== 'primary' && position !== 'secondary') return json(400, { error: 'The slot is primary or secondary' });
if (!item.cardId) return json(422, { error: `${item.id} has no Card` });
const messageId = typeof body.messageId === 'string' ? body.messageId.trim() : '';
if (!/^[A-Za-z0-9]{6,64}$/.test(messageId)) return json(400, { error: 'messageId is required: the Gmail message id of the email-sponsor send' });
try {
const route = await resolveGmailMessageRoute(messageId);
const taskIds = await applySponsorRoute(client, item.cardId, position, route);
return json(200, { issueId: item.id, slot: position, route, taskIds });
} catch (error) {
if (error instanceof GmailMessageRouteError || error instanceof SponsorRouteError) return json(422, { error: error.message, code: 'sponsor_route_not_set' });
throw error;
}
}

async function mailchimpMetricsRoute(id: string, body: Body, event: LambdaEvent, client: DynamoDBDocumentClient) {
const existing = await resolveNewsletterIssue(client, id);
if (!existing) return json(404, { error: 'Not found' });
Expand Down Expand Up @@ -1101,6 +1145,11 @@ export async function handleNewsletterIssueRoutes(
if (method !== 'GET') return json(405, { error: 'Method not allowed' });
return issueReportRoute(decodeURIComponent(issueReport[1]), event, client);
}
const issueReportSend = path.match(/^\/api\/newsletter-issues\/([^/]+)\/report\/send$/);
if (issueReportSend) {
if (method !== 'POST') return json(405, { error: 'Method not allowed' });
return issueReportSendRoute(decodeURIComponent(issueReportSend[1]), parse(event) || {}, event, client);
}
const mailchimpMetrics = path.match(/^\/api\/newsletter-issues\/([^/]+)\/mailchimp-metrics$/);
if (mailchimpMetrics) {
if (method !== 'POST') return json(405, { error: 'Method not allowed' });
Expand Down Expand Up @@ -1133,6 +1182,11 @@ export async function handleNewsletterIssueRoutes(
if (method !== 'GET') return json(405, { error: 'Method not allowed' });
return json(200, { items: await sponsorOptions(client) });
}
const slotRoute_ = path.match(/^\/api\/newsletter-issues\/([^/]+)\/slots\/([^/]+)\/route$/);
if (slotRoute_) {
if (method !== 'PUT') return json(405, { error: 'Method not allowed' });
return slotSponsorRouteRoute(decodeURIComponent(slotRoute_[1]), slotRoute_[2], parse(event) || {}, event, client);
}
const slot = path.match(/^\/api\/newsletter-issues\/([^/]+)\/slots\/([^/]+)$/);
if (slot) {
if (method !== 'PUT' && method !== 'DELETE') return json(405, { error: 'Method not allowed' });
Expand Down
Loading