backend-manager 5.1.2 → 5.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/.claude/settings.local.json +12 -0
  2. package/CHANGELOG.md +52 -0
  3. package/CLAUDE.md +2 -1
  4. package/README.md +30 -0
  5. package/docs/common-mistakes.md +1 -0
  6. package/docs/consent.md +333 -0
  7. package/docs/marketing-campaigns.md +41 -4
  8. package/docs/testing.md +81 -0
  9. package/package.json +1 -1
  10. package/src/cli/commands/emulator.js +62 -9
  11. package/src/cli/commands/serve.js +73 -7
  12. package/src/cli/commands/test.js +65 -1
  13. package/src/cli/commands/watch.js +15 -3
  14. package/src/defaults/CLAUDE.md +7 -5
  15. package/src/manager/events/cron/daily/marketing-newsletter-generate.js +24 -8
  16. package/src/manager/helpers/user.js +29 -0
  17. package/src/manager/index.js +111 -5
  18. package/src/manager/libraries/ai/index.js +21 -0
  19. package/src/manager/libraries/ai/providers/openai.js +75 -0
  20. package/src/manager/libraries/email/data/disposable-domains.json +20 -0
  21. package/src/manager/libraries/email/generators/lib/image-host.js +58 -6
  22. package/src/manager/libraries/email/generators/lib/markdown-renderer.js +285 -0
  23. package/src/manager/libraries/email/generators/lib/structure.js +19 -2
  24. package/src/manager/libraries/email/generators/lib/svg-illustrator.js +12 -3
  25. package/src/manager/libraries/email/generators/lib/templates/classic-schema.js +9 -13
  26. package/src/manager/libraries/email/generators/lib/templates/clean.js +1 -1
  27. package/src/manager/libraries/email/generators/lib/templates/editorial.js +1 -1
  28. package/src/manager/libraries/email/generators/lib/templates/field-report.js +10 -14
  29. package/src/manager/libraries/email/generators/newsletter.js +154 -7
  30. package/src/manager/libraries/email/providers/beehiiv.js +8 -1
  31. package/src/manager/libraries/payment/processors/stripe.js +12 -0
  32. package/src/manager/libraries/payment/processors/test.js +8 -1
  33. package/src/manager/routes/admin/infer-contact/post.js +3 -2
  34. package/src/manager/routes/admin/post/post.js +3 -3
  35. package/src/manager/routes/marketing/email-preferences/post.js +165 -37
  36. package/src/manager/routes/marketing/webhook/forward/post.js +168 -0
  37. package/src/manager/routes/marketing/webhook/post.js +180 -0
  38. package/src/manager/routes/marketing/webhook/processors/beehiiv.js +196 -0
  39. package/src/manager/routes/marketing/webhook/processors/sendgrid.js +171 -0
  40. package/src/manager/routes/payments/cancel/post.js +2 -2
  41. package/src/manager/routes/payments/cancel/processors/test.js +5 -2
  42. package/src/manager/routes/payments/intent/processors/test.js +7 -3
  43. package/src/manager/routes/payments/refund/processors/test.js +4 -1
  44. package/src/manager/routes/test/health/get.js +17 -0
  45. package/src/manager/routes/user/signup/post.js +65 -1
  46. package/src/manager/schemas/marketing/email-preferences/post.js +8 -4
  47. package/src/manager/schemas/marketing/webhook/forward/post.js +8 -0
  48. package/src/manager/schemas/marketing/webhook/post.js +7 -0
  49. package/src/manager/schemas/payments/cancel/post.js +5 -0
  50. package/src/manager/schemas/user/signup/post.js +5 -0
  51. package/src/test/run-tests.js +30 -0
  52. package/src/test/runner.js +72 -26
  53. package/src/test/test-accounts.js +94 -12
  54. package/src/test/utils/http-client.js +4 -3
  55. package/src/test/utils/test-mode-file.js +192 -0
  56. package/test/events/payments/journey-payments-cancel-endpoint.js +3 -12
  57. package/test/events/payments/journey-payments-cancel.js +4 -5
  58. package/test/events/payments/journey-payments-failure.js +0 -1
  59. package/test/events/payments/journey-payments-one-time-failure.js +6 -3
  60. package/test/events/payments/journey-payments-one-time.js +6 -3
  61. package/test/events/payments/journey-payments-plan-change.js +5 -5
  62. package/test/events/payments/journey-payments-refund-webhook.js +2 -3
  63. package/test/events/payments/journey-payments-suspend.js +4 -5
  64. package/test/events/payments/journey-payments-trial-cancel.js +3 -12
  65. package/test/events/payments/journey-payments-trial.js +2 -3
  66. package/test/events/payments/journey-payments-uid-resolution.js +2 -3
  67. package/test/functions/admin/database-read.js +0 -14
  68. package/test/functions/admin/database-write.js +0 -14
  69. package/test/functions/admin/firestore-query.js +0 -14
  70. package/test/functions/admin/firestore-read.js +0 -15
  71. package/test/functions/admin/firestore-write.js +0 -11
  72. package/test/functions/general/add-marketing-contact.js +16 -14
  73. package/test/helpers/email.js +1 -1
  74. package/test/helpers/infer-contact.js +3 -3
  75. package/test/helpers/user.js +241 -2
  76. package/test/helpers/webhook-forward.js +392 -0
  77. package/test/marketing/fixtures/clean.json +2 -3
  78. package/test/marketing/fixtures/editorial.json +2 -3
  79. package/test/marketing/fixtures/field-report.json +3 -4
  80. package/test/marketing/newsletter-generate.js +78 -54
  81. package/test/marketing/newsletter-templates.js +12 -33
  82. package/test/routes/admin/create-post.js +2 -2
  83. package/test/routes/admin/database.js +0 -13
  84. package/test/routes/admin/firestore-query.js +0 -13
  85. package/test/routes/admin/firestore.js +0 -14
  86. package/test/routes/admin/infer-contact.js +6 -3
  87. package/test/routes/admin/post.js +4 -2
  88. package/test/routes/marketing/contact.js +60 -26
  89. package/test/routes/marketing/email-preferences.js +145 -69
  90. package/test/routes/marketing/webhook-forward.js +54 -0
  91. package/test/routes/marketing/webhook.js +582 -0
  92. package/test/routes/payments/cancel.js +2 -7
  93. package/test/routes/payments/dispute-alert.js +0 -39
  94. package/test/routes/payments/refund.js +3 -1
  95. package/test/routes/payments/webhook.js +5 -26
  96. package/test/routes/test/usage.js +2 -2
  97. package/test/routes/user/signup.js +114 -0
@@ -0,0 +1,285 @@
1
+ /**
2
+ * Markdown renderer — deterministic, no AI cost. Walks the `structure` object
3
+ * produced by structure.js and emits a markdown document suitable for pasting
4
+ * into Beehiiv's block editor (one section per `## heading` block, so you can
5
+ * drop ad blocks between dispatches).
6
+ *
7
+ * Why this exists:
8
+ * - The MJML-rendered HTML is one giant styled block. Pasting it into Beehiiv
9
+ * defeats the block editor — ads can't be inserted between sections.
10
+ * - Markdown gives Beehiiv (and any future provider) a clean per-section
11
+ * structure that maps to native blocks.
12
+ *
13
+ * Why programmatic, not AI:
14
+ * - Cost: zero. Layout never changes between issues for a given template.
15
+ * - Determinism: same `structure` → same markdown. Easy to test.
16
+ * - SSOT: the AI-authored `structure` is the source of truth; markdown and
17
+ * HTML are two views of the same data.
18
+ *
19
+ * Template-awareness:
20
+ * - `clean`/`editorial` use the classic shape (intro + sections[]).
21
+ * - `field-report` uses dispatches[] with kickers, bylines, dataPoints.
22
+ * - The renderer reads `structure._meta.template` (set by structure.js) to
23
+ * pick the body strategy; falls back to "classic sections" if absent.
24
+ *
25
+ * Sections render as standalone blocks so each can be pasted independently:
26
+ * - heading (## ...)
27
+ * - image (markdown ![alt](url) — only if image URL is present)
28
+ * - body
29
+ * - CTA as a markdown link on its own line
30
+ * - horizontal rule between sections (---)
31
+ */
32
+
33
+ /**
34
+ * Render a newsletter structure as markdown.
35
+ *
36
+ * @param {object} args
37
+ * @param {object} args.structure - The output of generateStructure()
38
+ * @param {object} [args.brand] - { name, url, id }
39
+ * @param {string[]} [args.imagePaths] - Per-section image URLs (same order as sections/dispatches)
40
+ * @param {Array<{position, html, image_url, link_url}>} [args.sponsorships] - Optional sponsorships
41
+ * @returns {string} Markdown document
42
+ */
43
+ function renderMarkdown({ structure, brand, imagePaths, sponsorships }) {
44
+ if (!structure) {
45
+ throw new Error('markdown-renderer: structure is required');
46
+ }
47
+
48
+ const template = structure._meta?.template || 'clean';
49
+ const parts = [];
50
+
51
+ // ----- Header -----
52
+ if (structure.subject) {
53
+ parts.push(`# ${structure.subject}`);
54
+ }
55
+
56
+ if (structure.preheader) {
57
+ parts.push(`_${structure.preheader}_`);
58
+ }
59
+
60
+ // Field-report-only opener: TLDR strip + dateline
61
+ if (template === 'field-report') {
62
+ if (structure.dateline) {
63
+ parts.push(`**${structure.dateline.toUpperCase()} —** _Filed today_`);
64
+ }
65
+
66
+ if (structure.tldr) {
67
+ parts.push(`> **TL;DR** — ${structure.tldr}`);
68
+ }
69
+ }
70
+
71
+ // Classic intro
72
+ if (structure.intro) {
73
+ parts.push(structure.intro);
74
+ }
75
+
76
+ // Top sponsorship (above sections)
77
+ const topSponsorship = pickSponsorship(sponsorships, 'top');
78
+ if (topSponsorship) {
79
+ parts.push('---');
80
+ parts.push(renderSponsorshipMarkdown(topSponsorship));
81
+ }
82
+
83
+ parts.push('---');
84
+
85
+ // ----- Body -----
86
+ const sections = getBodySections(structure, template);
87
+
88
+ for (let i = 0; i < sections.length; i++) {
89
+ const block = renderSection(sections[i], i, imagePaths, template);
90
+
91
+ if (!block) {
92
+ continue;
93
+ }
94
+
95
+ parts.push(block);
96
+ parts.push('---');
97
+
98
+ // Mid-section sponsorship (after the middle dispatch)
99
+ const middleIdx = Math.floor(sections.length / 2);
100
+
101
+ if (i === middleIdx - 1) {
102
+ const midSponsorship = pickSponsorship(sponsorships, 'middle');
103
+
104
+ if (midSponsorship) {
105
+ parts.push(renderSponsorshipMarkdown(midSponsorship));
106
+ parts.push('---');
107
+ }
108
+ }
109
+ }
110
+
111
+ // End sponsorship (above signoff)
112
+ const endSponsorship = pickSponsorship(sponsorships, 'end');
113
+ if (endSponsorship) {
114
+ parts.push(renderSponsorshipMarkdown(endSponsorship));
115
+ parts.push('---');
116
+ }
117
+
118
+ // ----- Footer -----
119
+ if (structure.signoff) {
120
+ // Signoffs are stored with literal "\n" — convert to a markdown line break.
121
+ parts.push(structure.signoff.replace(/\n/g, ' \n'));
122
+ }
123
+
124
+ if (Array.isArray(structure.citations) && structure.citations.length) {
125
+ parts.push('---');
126
+ parts.push('## Notes');
127
+
128
+ for (let i = 0; i < structure.citations.length; i++) {
129
+ const c = structure.citations[i];
130
+
131
+ if (!c?.note) {
132
+ continue;
133
+ }
134
+
135
+ const src = c.source ? ` — _${c.source}_` : '';
136
+ parts.push(`${i + 1}. ${c.note}${src}`);
137
+ }
138
+ }
139
+
140
+ if (Array.isArray(structure.tags) && structure.tags.length) {
141
+ parts.push(`_Tags: ${structure.tags.map((t) => `#${t}`).join(' ')}_`);
142
+ }
143
+
144
+ if (brand?.name && brand?.url) {
145
+ parts.push(`---\n_You're receiving this because you subscribed to [${brand.name}](${brand.url})._`);
146
+ }
147
+
148
+ // Join with blank-line separators; collapse any accidental triple-newlines.
149
+ return parts.join('\n\n').replace(/\n{3,}/g, '\n\n').trim() + '\n';
150
+ }
151
+
152
+ /**
153
+ * Extract the "body sections" — what we'll loop over to render as ## headings.
154
+ * The shape depends on the template.
155
+ */
156
+ function getBodySections(structure, template) {
157
+ if (template === 'field-report' && Array.isArray(structure.dispatches)) {
158
+ return structure.dispatches.map((d) => ({
159
+ kind: 'dispatch',
160
+ title: d.headline,
161
+ kicker: d.kicker,
162
+ byline: d.byline,
163
+ location: d.location,
164
+ lede: d.lede,
165
+ body: d.dispatch,
166
+ dataPoints: d.dataPoints,
167
+ image_prompt: d.image_prompt,
168
+ }));
169
+ }
170
+
171
+ // Classic shape (clean, editorial)
172
+ if (Array.isArray(structure.sections)) {
173
+ return structure.sections.map((s) => ({
174
+ kind: 'section',
175
+ title: s.title,
176
+ body: s.body,
177
+ image_prompt: s.image_prompt,
178
+ }));
179
+ }
180
+
181
+ return [];
182
+ }
183
+
184
+ /**
185
+ * Render a single section/dispatch as a self-contained markdown block.
186
+ * Empty sections (no title and no body) return null so the caller can skip
187
+ * them entirely — avoids "## undefined" stubs and dangling `---` dividers.
188
+ */
189
+ function renderSection(section, idx, imagePaths, template) {
190
+ if (!section?.title && !section?.body) {
191
+ return null;
192
+ }
193
+
194
+ const lines = [];
195
+
196
+ // Kicker prefix (field-report only)
197
+ if (section.kicker) {
198
+ lines.push(`**${section.kicker.toUpperCase()}**`);
199
+ }
200
+
201
+ if (section.title) {
202
+ lines.push(`## ${section.title}`);
203
+ }
204
+
205
+ // Byline / location bar (field-report only)
206
+ if (section.byline || section.location) {
207
+ const parts = [];
208
+ if (section.location) parts.push(`**${section.location.toUpperCase()}**`);
209
+ if (section.byline) parts.push(`_${section.byline}_`);
210
+ lines.push(parts.join(' — '));
211
+ }
212
+
213
+ // Image (if hosted)
214
+ const imageUrl = imagePaths && imagePaths[idx];
215
+
216
+ if (imageUrl && !imageUrl.startsWith('about:')) {
217
+ const alt = section.image_prompt || section.title || `Section ${idx + 1}`;
218
+ lines.push(`![${alt.replace(/[\[\]]/g, '')}](${imageUrl})`);
219
+ }
220
+
221
+ // Lede (field-report)
222
+ if (section.lede) {
223
+ lines.push(`_${section.lede}_`);
224
+ }
225
+
226
+ // Data points (field-report)
227
+ if (Array.isArray(section.dataPoints) && section.dataPoints.length) {
228
+ const rows = section.dataPoints
229
+ .map((dp) => `| **${dp.label || ''}** | ${dp.value || ''} |`)
230
+ .join('\n');
231
+ lines.push(`| Metric | Value |\n|---|---|\n${rows}`);
232
+ }
233
+
234
+ // Body
235
+ if (section.body) {
236
+ lines.push(section.body);
237
+ }
238
+
239
+ return lines.join('\n\n');
240
+ }
241
+
242
+ /**
243
+ * Pick the first sponsorship matching the requested position. Position is
244
+ * one of 'top' | 'middle' | 'end'. Returns null if no match.
245
+ */
246
+ function pickSponsorship(sponsorships, position) {
247
+ if (!Array.isArray(sponsorships) || !sponsorships.length) {
248
+ return null;
249
+ }
250
+
251
+ return sponsorships.find((s) => (s?.position || 'top') === position) || null;
252
+ }
253
+
254
+ /**
255
+ * Render a sponsorship as markdown. Sponsorships in the HTML template are
256
+ * styled blocks; in markdown they're a simple "Sponsored by" callout with
257
+ * optional image and link.
258
+ */
259
+ function renderSponsorshipMarkdown(sp) {
260
+ const lines = ['**Sponsored**'];
261
+
262
+ if (sp.image_url && sp.link_url) {
263
+ lines.push(`[![Sponsor](${sp.image_url})](${sp.link_url})`);
264
+ } else if (sp.image_url) {
265
+ lines.push(`![Sponsor](${sp.image_url})`);
266
+ }
267
+
268
+ if (sp.html) {
269
+ // Strip HTML tags for the markdown view; keep the text.
270
+ const text = String(sp.html).replace(/<[^>]+>/g, '').trim();
271
+ if (text) {
272
+ lines.push(text);
273
+ }
274
+ }
275
+
276
+ if (sp.link_url) {
277
+ lines.push(`**[Learn more →](${sp.link_url})**`);
278
+ }
279
+
280
+ return lines.join('\n\n');
281
+ }
282
+
283
+ module.exports = {
284
+ renderMarkdown,
285
+ };
@@ -44,11 +44,23 @@ const DEFAULT_MODELS = {
44
44
  const BASE_SCHEMA = {
45
45
  type: 'object',
46
46
  additionalProperties: false,
47
- required: ['subject', 'preheader', 'signoff', 'citations'],
47
+ required: ['subject', 'preheader', 'signoff', 'citations', 'tags', 'summary'],
48
48
  properties: {
49
49
  subject: { type: 'string', maxLength: 80 },
50
50
  preheader: { type: 'string', maxLength: 120 },
51
51
  signoff: { type: 'string' },
52
+ // Two-to-three-sentence editorial summary of the issue. Used as the body of
53
+ // `summary.md` alongside the newsletter, and as a preview snippet when the
54
+ // issue is shared. Distinct from preheader (which is an inbox-preview hook).
55
+ summary: { type: 'string', maxLength: 600 },
56
+ // Topical tags for the issue. Flow into Beehiiv's `content_tags` field on
57
+ // the post draft (array of strings, lowercase, kebab-case preferred).
58
+ // Empty array is valid.
59
+ tags: {
60
+ type: 'array',
61
+ maxItems: 5,
62
+ items: { type: 'string', maxLength: 40 },
63
+ },
52
64
  // Citations for hard data (statistics, numbers, direct quotes) pulled from sources.
53
65
  // Rendered as a small footnote section at the bottom of the newsletter — never inline.
54
66
  // Empty array is valid (most newsletters won't need citations).
@@ -169,6 +181,8 @@ async function generateStructure({ sources, brand, newsletterConfig, ai, assista
169
181
  structure.preheader = structure.preheader || '';
170
182
  structure.signoff = structure.signoff || `Best,\nThe ${brand?.name || 'Team'} Team`;
171
183
  structure.citations = Array.isArray(structure.citations) ? structure.citations : [];
184
+ structure.tags = Array.isArray(structure.tags) ? structure.tags : [];
185
+ structure.summary = typeof structure.summary === 'string' ? structure.summary : '';
172
186
 
173
187
  // Let the template normalize its own fields (e.g. sections defaults).
174
188
  // Falls back to a sane default if the template doesn't ship one.
@@ -226,10 +240,13 @@ function buildClassicSystemPrompt(brand, config) {
226
240
  'CONTENT REQUIREMENTS:',
227
241
  '- Subject (≤60 chars, no emojis, attention-grabbing but not clickbait)',
228
242
  '- Preheader (≤100 chars, complements the subject)',
243
+ '- Summary (2-3 sentences, plain text, no markdown) — an editorial recap of the issue, written like a TL;DR. Distinct from preheader (which is an inbox hook). This is what someone reads if they only have 10 seconds.',
244
+ '- Tags (3-5 short topical tags, lowercase, kebab-case, no spaces) — e.g. "linkedin", "creator-economy", "platform-policy". Empty array is fine if nothing fits.',
229
245
  '- Intro (1-2 sentences, markdown allowed) — frame the issue as if you are setting up your own reporting',
230
246
  '- 3-5 sections — each is ONE topic, rewritten in your voice as original content',
231
- '- Each section: title (compelling, scannable), body (80-150 words, markdown OK), optional cta { label, url }',
247
+ '- Each section: title (compelling, scannable), body (80-150 words, markdown OK)',
232
248
  '- Each section: image_prompt — one-sentence visual description for an illustrator. Be specific about subject/style.',
249
+ '- Do NOT include CTAs, "read more" links, or any URLs in section bodies. The newsletter is a self-contained read — never invent links or send readers off-property.',
233
250
  `- Signoff: a SHORT human sign-off, formatted as two lines with \\n between them. First line is a closing phrase like "Best,", "Cheers,", "Until next week,", or "Stay sharp,". Second line is the team name like "The ${brand?.name || 'Team'} Team". Example: "Best,\\nThe ${brand?.name || 'Team'} Team". Do NOT write a summary, tagline, motto, or thematic conclusion sentence — this is the literal way you sign off the email, like the end of a letter.`,
234
251
  '- citations: array of { note, source } for any hard data referenced. Empty array if none.',
235
252
  '',
@@ -2,16 +2,25 @@
2
2
  * SVG illustrator — AI authors per-section SVG illustrations, rasterized to PNG.
3
3
  *
4
4
  * Each section in a newsletter gets one illustration. Default provider is
5
- * Anthropic (Claude generates cleaner geometric SVG than GPT in practice).
5
+ * OpenAI Codex (gpt-5.3-codex) markup/code-specialized GPT-5 variant tuned
6
+ * for structured output. SVG is just structured markup, so Codex is the right
7
+ * fit. Anthropic is supported as a fallback provider.
6
8
  *
7
9
  * Output is both the raw SVG string (for debugging) and a rasterized PNG buffer
8
10
  * (for embedding). Local file persistence is the caller's responsibility — this
9
11
  * module returns buffers only.
12
+ *
13
+ * Provider-specific default models:
14
+ * openai → gpt-5.3-codex (Codex family is markup/code-specialized; SVG is
15
+ * structured markup. ~$0.005-0.015/image.)
16
+ * anthropic → claude-opus (Claude is good at artistic SVG.)
10
17
  */
11
18
  const { Resvg } = require('@resvg/resvg-js');
12
19
 
20
+ const DEFAULT_PROVIDER = 'openai';
21
+
13
22
  const DEFAULT_MODELS = {
14
- openai: 'gpt-5.4-mini',
23
+ openai: 'gpt-5.3-codex',
15
24
  anthropic: 'claude-opus',
16
25
  'claude-code': 'claude-opus-4-7',
17
26
  };
@@ -30,7 +39,7 @@ const PNG_WIDTH = 800; // 2x display width of 400px container
30
39
  * @returns {Promise<{svg: string, png: Buffer, fallback: boolean}>}
31
40
  */
32
41
  async function generateSectionImage({ imagePrompt, brand, newsletterConfig, ai, assistant }) {
33
- const provider = newsletterConfig?.provider?.svg || 'anthropic';
42
+ const provider = newsletterConfig?.provider?.svg || DEFAULT_PROVIDER;
34
43
  const model = newsletterConfig?.model?.svg || DEFAULT_MODELS[provider];
35
44
  const startTime = Date.now();
36
45
 
@@ -3,12 +3,18 @@
3
3
  *
4
4
  * Both templates consume the same shape:
5
5
  * - intro: 1-2 sentence preamble
6
- * - sections: list of {title, body, cta?, image_prompt}
6
+ * - sections: list of {title, body, image_prompt}
7
7
  *
8
8
  * Defined here so adding a new field (e.g. an eyebrow per section) updates
9
9
  * every classic-style template at once. Templates with fundamentally
10
10
  * different content shapes (Field Report, Postcard, Almanac) declare their
11
11
  * own schema instead.
12
+ *
13
+ * NOTE: CTAs / outbound links are intentionally NOT part of the schema. The
14
+ * AI cannot reliably author URLs without inventing them (it can't browse the
15
+ * brand's site and has no real source URLs), so we forbid the field entirely.
16
+ * Newsletters are self-contained — link out from the rendered HTML manually
17
+ * (sponsorship blocks, footer) rather than from generated section bodies.
12
18
  */
13
19
  const CLASSIC_SCHEMA = {
14
20
  required: ['intro', 'sections'],
@@ -21,19 +27,10 @@ const CLASSIC_SCHEMA = {
21
27
  items: {
22
28
  type: 'object',
23
29
  additionalProperties: false,
24
- required: ['title', 'body', 'image_prompt', 'cta'],
30
+ required: ['title', 'body', 'image_prompt'],
25
31
  properties: {
26
32
  title: { type: 'string' },
27
33
  body: { type: 'string' },
28
- cta: {
29
- type: ['object', 'null'],
30
- additionalProperties: false,
31
- required: ['label', 'url'],
32
- properties: {
33
- label: { type: 'string' },
34
- url: { type: 'string' },
35
- },
36
- },
37
34
  image_prompt: { type: 'string' },
38
35
  },
39
36
  },
@@ -43,7 +40,7 @@ const CLASSIC_SCHEMA = {
43
40
 
44
41
  /**
45
42
  * Normalize a classic structure post-AI-call. Ensures every section has the
46
- * fields the templates expect, even when the AI omits an optional like cta.
43
+ * fields the templates expect, even when the AI omits an optional.
47
44
  */
48
45
  function normalizeClassic(structure) {
49
46
  if (!Array.isArray(structure.sections)) {
@@ -53,7 +50,6 @@ function normalizeClassic(structure) {
53
50
  structure.sections = structure.sections.map((s, i) => ({
54
51
  title: s.title || `Section ${i + 1}`,
55
52
  body: s.body || '',
56
- cta: s.cta || null,
57
53
  image_prompt: s.image_prompt || '',
58
54
  }));
59
55
 
@@ -74,7 +74,7 @@ module.exports = {
74
74
  name: 'clean',
75
75
  description: 'Stripe / Linear marketing aesthetic. Safe, conservative, works everywhere.',
76
76
  requires: ['subject', 'preheader', 'intro', 'sections', 'signoff'],
77
- optional: ['citations', 'image_prompt', 'cta'],
77
+ optional: ['citations', 'image_prompt'],
78
78
  supports: { sponsorships: ['top', 'middle', 'end'], citations: true, images: true },
79
79
  },
80
80
  schema: CLASSIC_SCHEMA,
@@ -309,7 +309,7 @@ module.exports = {
309
309
  name: 'editorial',
310
310
  description: 'Magazine-style: masthead, drop-cap intro, numbered sections, pull-quotes, italic signoff.',
311
311
  requires: ['subject', 'preheader', 'intro', 'sections', 'signoff'],
312
- optional: ['citations', 'image_prompt', 'cta'],
312
+ optional: ['citations', 'image_prompt'],
313
313
  supports: { sponsorships: ['top', 'middle', 'end'], citations: true, images: true },
314
314
  },
315
315
  schema: CLASSIC_SCHEMA,
@@ -16,7 +16,7 @@
16
16
  *
17
17
  * Content schema (template-owned — different from the classic schema):
18
18
  * { tldr, dateline, dispatches: [{ kicker, headline, byline, location,
19
- * lede, dispatch, dataPoints?, cta?, image_prompt }] }
19
+ * lede, dispatch, dataPoints?, image_prompt }] }
20
20
  */
21
21
  const {
22
22
  shell,
@@ -337,7 +337,7 @@ const FIELD_REPORT_SCHEMA = {
337
337
  items: {
338
338
  type: 'object',
339
339
  additionalProperties: false,
340
- required: ['kicker', 'headline', 'byline', 'location', 'lede', 'dispatch', 'image_prompt', 'cta', 'dataPoints'],
340
+ required: ['kicker', 'headline', 'byline', 'location', 'lede', 'dispatch', 'image_prompt', 'dataPoints'],
341
341
  properties: {
342
342
  kicker: { type: 'string', maxLength: 30 }, // "DISPATCH" / "FIELD NOTES" / "WATCH" / "BRIEF"
343
343
  headline: { type: 'string', maxLength: 90 }, // Tight, declarative
@@ -358,15 +358,10 @@ const FIELD_REPORT_SCHEMA = {
358
358
  },
359
359
  },
360
360
  },
361
- cta: {
362
- type: ['object', 'null'],
363
- additionalProperties: false,
364
- required: ['label', 'url'],
365
- properties: {
366
- label: { type: 'string' },
367
- url: { type: 'string' },
368
- },
369
- },
361
+ // CTAs intentionally not part of the contract — the AI cannot author URLs
362
+ // reliably (no source URLs available, no brand-site knowledge). Newsletters
363
+ // are self-contained; outbound links come from sponsorship blocks rendered
364
+ // by the template shell, not from generated dispatch bodies.
370
365
  image_prompt: { type: 'string' },
371
366
  },
372
367
  },
@@ -390,7 +385,6 @@ function normalizeFieldReport(structure, { brand } = {}) {
390
385
  lede: d.lede || '',
391
386
  dispatch: d.dispatch || '',
392
387
  dataPoints: Array.isArray(d.dataPoints) ? d.dataPoints.slice(0, 4) : [],
393
- cta: d.cta || null,
394
388
  image_prompt: d.image_prompt || '',
395
389
  }));
396
390
 
@@ -439,6 +433,8 @@ function buildPrompt({ brand, newsletterConfig, sources }) {
439
433
  'CONTENT REQUIREMENTS:',
440
434
  '- subject: ≤60 chars, declarative, no clickbait. Reads like a wire-service headline.',
441
435
  '- preheader: ≤100 chars, complements subject.',
436
+ '- summary: 2-3 sentences, plain text, no markdown. An editorial recap of the whole issue (distinct from the in-template `tldr` strip — the summary is consumed by external surfaces like the share preview / summary.md file).',
437
+ '- tags: 3-5 topical tags. Lowercase, kebab-case, no spaces. Examples: "linkedin", "creator-economy", "platform-policy". Empty array OK if nothing fits cleanly.',
442
438
  '- tldr: 2 short sentences max, ~200 chars total. Present tense. Reads like a terminal briefing — what changed, why it matters.',
443
439
  '- dateline: one city or "REMOTE" — sets where the issue is filed from. UPPERCASE. Example: "LOS ANGELES" / "REMOTE" / "NEW YORK".',
444
440
  '- dispatches: 3-5 items, each is a discrete filed story.',
@@ -449,8 +445,8 @@ function buildPrompt({ brand, newsletterConfig, sources }) {
449
445
  ' - lede: one paragraph (1-2 sentences), italic-serif quality, sets the scene in present tense. Reads like the opening of a New Yorker article.',
450
446
  ' - dispatch: the body. 90-160 words. Markdown allowed. Present tense. Specific. End with the practical implication for the reader.',
451
447
  ' - dataPoints: 2-4 short label/value pairs IF there are meaningful numbers in the topic. Example: [{label:"USERS REACHED",value:"12.4K"},{label:"WoW GROWTH",value:"+38%"}]. SKIP this (empty array) if the topic has no quantifiable data.',
452
- ' - cta: { label, url } OR null. Label is mono, uppercase, short (≤24 chars). Examples: "READ THE BRIEF", "SEE THE NUMBERS".',
453
448
  ' - image_prompt: one-sentence visual brief for an illustrator. Specific. Think editorial illustration, not stock photo.',
449
+ ' - DO NOT invent CTAs, "read more" links, or any URLs anywhere in the dispatch. The dispatch must stand on its own without sending the reader off-property.',
454
450
  `- signoff: render as TWO LINES with a literal \\n between them. Format: a short closing phrase + the desk name. Examples:\n "— Stay sharp,\\nThe ${brandName} Desk"\n "— Until next dispatch,\\nThe ${brandName} Editorial Desk"\n Do NOT write a summary, motto, or thematic sentence. This is a literal sign-off.`,
455
451
  '',
456
452
  'OUTPUT:',
@@ -488,7 +484,7 @@ module.exports = {
488
484
  name: 'field-report',
489
485
  description: 'Wire-service correspondent × Bloomberg terminal. Dispatch kickers, datelines, mono data callouts, end-of-dispatch terminators.',
490
486
  requires: ['subject', 'preheader', 'tldr', 'dateline', 'dispatches', 'signoff'],
491
- optional: ['citations', 'dataPoints', 'cta', 'image_prompt'],
487
+ optional: ['citations', 'dataPoints', 'image_prompt'],
492
488
  supports: { sponsorships: ['top', 'middle', 'end'], citations: true, images: true },
493
489
  },
494
490
  schema: FIELD_REPORT_SCHEMA,