backend-manager 5.2.16 → 5.2.17

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.
package/CHANGELOG.md CHANGED
@@ -14,6 +14,19 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
14
14
  - `Fixed` for any bug fixes.
15
15
  - `Security` in case of vulnerabilities.
16
16
 
17
+ # [5.2.17] - 2026-05-29
18
+
19
+ ### Added
20
+
21
+ - **Newsletter-driven blog articles (`marketing.beehiiv.content.article`).** When enabled, the newsletter generator expands its **lead section** (`structure.sections[0]`) into a full blog article via the Ghostii engine, publishes it to the website repo through the `admin/post` route, and injects a "Read the full article" CTA (`section.cta = { label, url }`) onto that section so the newsletter links to the long-form post. The article build runs **concurrently** with SVG image generation (both are slow AI calls) and is **failure-isolated** — if it throws, no CTA is injected and the newsletter ships normally. The published URL (`{brand.url}/blog/{slug}`) is surfaced on the generator return as `assets.articleUrl` and `meta.article`. New config block: `content.article = { enabled, author }` (`enabled` default `false`). See `docs/marketing-campaigns.md`.
22
+ - **`section.cta` rendering.** Both the MJML template (`sectionCard`, already supported) and the markdown renderer (`lib/markdown-renderer.js`, new) now render `section.cta = { label, url }` when present. `getBodySections` passes `cta` through for both classic and field-report shapes. CTAs are still never authored by the AI — they're injected by code post-publish with a real, verified URL.
23
+ - **Generate/publish split on the linked article (`opts.publishArticle`).** When `config.article.enabled` is on, the article is always **generated** (Ghostii writes it + the public URL is computed from the title slug + the CTA is injected), but it's only **committed** to the website repo via `admin/post` when `opts.publishArticle` is true. The production cron passes `publishArticle: true`. The newsletter iteration test (`test/marketing/newsletter-generate.js`) leaves it false by default — so a full `TEST_EXTENDED_MODE=1` run exercises the Ghostii write + CTA path without committing a real post — and opts in with `NEWSLETTER_CREATE_ARTICLE=1`. The CTA URL is valid either way (derived from the same slugify `admin/post` uses). `meta.article.published` records whether the post was actually committed.
24
+
25
+ ### Changed
26
+
27
+ - **Extracted the Ghostii article engine into `src/manager/libraries/content/ghostii.js`** (`writeArticle` + `publishArticle`) as the SSOT for the Ghostii API request shape and the `admin/post` publish payload. Both the standalone `ghostii-auto-publisher.js` daily cron and the new newsletter linked-article flow import it (DRY). No behavior change to the standalone cron.
28
+ - **Standalone Ghostii is now disabled by default** (`ghostii[0].articles: 0` in `templates/backend-manager-config.json`). The daily `ghostii-auto-publisher.js` cron remains fully functional — set `articles >= 1` to opt back into independent article publishing. For newsletter-linked articles, use `content.article.enabled` instead.
29
+
17
30
  # [5.2.16] - 2026-05-28
18
31
 
19
32
  ### Removed
package/CLAUDE.md CHANGED
@@ -34,7 +34,7 @@ All `npx mgr <cmd>` aliases work: `npx bm <cmd>`, `npx bem <cmd>`, `npx backend-
34
34
  1. `npm install` — install BEM's own deps
35
35
  2. `npm run prepare` — build once: copies `src/` → `dist/` via prepare-package
36
36
  3. `npm run prepare:watch` — watch mode
37
- 4. Test in a consumer project: from inside the consumer's `functions/` dir, run `npx mgr install local` (swaps BEM to the local repo via the `install` CLI). Reverse with `npx mgr install prod`.
37
+ 4. Test in a consumer project: from inside the consumer, run `npx mgr install dev` to swap BEM to this local repo required whenever you edit the framework source and want the consumer to pick up the changes (the consumer otherwise keeps its installed `node_modules/backend-manager`). Reverse with `npx mgr install prod`. If `npx mgr` then errors with "could not determine executable to run", the local install skipped bin-linking — re-run `npm install` to relink, or call `node node_modules/backend-manager/bin/backend-manager <cmd>` directly.
38
38
 
39
39
  ## Architecture
40
40
 
@@ -119,9 +119,9 @@ Deep references live in `docs/`. **Whenever you make a behavioral change, update
119
119
 
120
120
  ### Built-in Routes
121
121
 
122
- - [docs/admin-post-route.md](docs/admin-post-route.md) — `POST/PUT /admin/post` blog creation via GitHub (image extraction + resize at ingest + `@post/` rewriting)
122
+ - [docs/admin-post-route.md](docs/admin-post-route.md) — `POST/PUT /admin/post` blog creation via GitHub (image extraction + resize at ingest + `@post/` rewriting). Also the publish target for the Ghostii article engine (`libraries/content/ghostii.js`).
123
123
  - [docs/payment-system.md](docs/payment-system.md) — full payment pipeline: Intent → Webhook → On-Write → Transition; subscription model, statuses, `resolveSubscription()`, transition handlers, processor interface, product config, test processor
124
- - [docs/marketing-campaigns.md](docs/marketing-campaigns.md) — campaign CRUD routes, recurring campaigns, generator pipeline (newsletter), template-owned schemas, asset hosting, seed campaigns
124
+ - [docs/marketing-campaigns.md](docs/marketing-campaigns.md) — campaign CRUD routes, recurring campaigns, generator pipeline (newsletter), newsletter-driven blog article (`content.article.enabled`), template-owned schemas, asset hosting, seed campaigns
125
125
  - [docs/consent.md](docs/consent.md) — marketing consent capture: canonical `consent.{legal,marketing}` user-doc shape, signup-form capture, account-page toggle, HMAC unsub link, SendGrid+Beehiiv webhook receivers, parent forwarder (`/marketing/webhook/forward`), migration script template
126
126
  - [docs/mcp.md](docs/mcp.md) — Model Context Protocol server: 19 tools, stdio + HTTP transports, OAuth, Claude Chat/Code configuration
127
127
 
package/README.md CHANGED
@@ -412,6 +412,7 @@ Built-in marketing system with multi-provider support (SendGrid + Beehiiv + FCM
412
412
  - **Campaign CRUD** — `POST/GET/PUT/DELETE /marketing/campaign` with calendar-backed scheduling
413
413
  - **Recurring campaigns** — seasonal sales, newsletters with automatic sendAt advancement
414
414
  - **Newsletter generator** — AI-assembled newsletters from parent server content sources
415
+ - **Newsletter-driven blog articles** — `content.article.enabled` expands the newsletter's lead section into a full blog post (via Ghostii → `admin/post`) and links to it with a "Read the full article" CTA
415
416
  - **Segment SSOT** — 22 segment definitions resolved to provider IDs at runtime
416
417
  - **UTM auto-tagging** — brand domain links tagged automatically in marketing + transactional emails
417
418
  - **Contact pruning** — monthly 2-stage re-engagement + deletion of inactive contacts
@@ -2,6 +2,8 @@
2
2
 
3
3
  The `POST /admin/post` route creates blog posts via GitHub's API. It handles image extraction, resize, upload, and body rewriting.
4
4
 
5
+ **Consumers.** Besides direct admin/blogger HTTP calls, this route is the publish target for the **Ghostii article engine** (`src/manager/libraries/content/ghostii.js` → `publishArticle()`), which is used by two paths: the standalone daily `ghostii-auto-publisher.js` cron (off by default), and the newsletter generator's linked-article flow (`marketing.beehiiv.content.article.enabled`). Both POST `title`/`url`/`description`/`headerImageURL`/`body`/`author`/`categories`/`tags`/`postPath`/`githubUser`/`githubRepo` with a `backendManagerKey`. Note: `headerImageURL` MUST resolve to a `.jpg` (the downloader rejects non-`.jpg` headers); Ghostii's `unsplash` hero satisfies this. See [docs/marketing-campaigns.md](marketing-campaigns.md).
6
+
5
7
  ## Image Processing Flow
6
8
 
7
9
  1. Receives markdown body with external image URLs (e.g., `![alt](https://images.unsplash.com/...)`)
@@ -14,6 +14,23 @@ Return shape (same for all providers): `{ content, output, tokens, raw }`.
14
14
 
15
15
  API keys: `BACKEND_MANAGER_OPENAI_API_KEY`, `BACKEND_MANAGER_ANTHROPIC_API_KEY` (process.env or config).
16
16
 
17
+ ## Tools / web search (OpenAI)
18
+
19
+ `options.tools` (and optional `options.toolChoice`) are passed through to the OpenAI Responses API verbatim. Opt-in — when omitted, no tools are sent and behavior is identical to a plain request. Use this to enable OpenAI's built-in **web search** so the model finds and cites real, currently-live URLs instead of hallucinating them:
20
+
21
+ ```js
22
+ const r = await ai.request({
23
+ model: 'gpt-5.4',
24
+ response: 'json',
25
+ reasoning: { effort: 'medium' },
26
+ tools: [{ type: 'web_search' }],
27
+ prompt: { path: '.../research/system.md', settings },
28
+ message: { path: '.../research/user.md', settings },
29
+ });
30
+ ```
31
+
32
+ When tools are active, the response `output` array may contain tool-call items (e.g. `web_search_call`) alongside the `message`; the message-text extractor ignores non-message items, so `r.content` is unaffected. URL citations live in the returned `output` (message content) as `annotations` of type `url_citation`.
33
+
17
34
  ## `claude-code` provider — subscription billing
18
35
 
19
36
  The `claude-code` provider hits the same Claude Messages API as `anthropic`, but authenticates with a **Claude Code OAuth token** (`Authorization: Bearer ...` + `anthropic-beta: oauth-2025-04-20`) so usage bills the Claude Pro/Max subscription tied to the token rather than API credits.
@@ -100,6 +100,22 @@ Pipeline:
100
100
  4. **mjml-template.js** — Resolves the template by name from `templates/index.js`, calls `template.build({structure, imagePaths, theme, ...})` for the MJML, compiles to email-safe HTML via the `mjml` package. Brand-domain links get UTM-tagged via the existing `tagLinks()` utility.
101
101
  5. Mark used: `PUT {parentUrl}/newsletter/sources` per source
102
102
 
103
+ > **Step 3 runs concurrently with the linked-article build** (see below) when `article.enabled` is on — both are slow AI calls, so they share one `Promise.all`. The article URL is stamped onto the lead section before steps 4/5 render.
104
+
105
+ ## Newsletter-driven blog article (`article.enabled`)
106
+
107
+ When `marketing.beehiiv.content.article.enabled: true`, the newsletter generator expands its **lead section** (`structure.sections[0]`) into a full blog article and links to it from the newsletter.
108
+
109
+ Flow (runs in parallel with SVG generation, between structure and render):
110
+ 1. `buildLinkedArticle()` builds a brief from the lead section's title + body, folded with the shared `tone` + `instructions`.
111
+ 2. **Ghostii** (`libraries/content/ghostii.js` → `writeArticle()`) expands it into a full article + hero image.
112
+ 3. `publishArticle()` POSTs to the `admin/post` route (commits markdown + images to the website repo). Public URL = `{brand.url}/blog/{slug}`.
113
+ 4. The URL is injected as `structure.sections[0].cta = { label: 'Read the full article', url }`. The MJML `sectionCard` and the markdown renderer both render `section.cta` automatically.
114
+
115
+ **Failure is isolated** — if the article build throws, it resolves to `null`, no CTA is injected, and the newsletter ships normally. The newsletter never depends on the article succeeding.
116
+
117
+ The published URL is surfaced on the return as `assets.articleUrl` and `meta.article`. Config block: `marketing.beehiiv.content.article = { enabled, author }` (`enabled` default `false`; `author` is the post author slug). Standalone article publishing (independent of the newsletter) still lives in the daily `ghostii-auto-publisher.js` cron, which now shares the same `libraries/content/ghostii.js` engine — see [docs/admin-post-route.md](admin-post-route.md). Standalone Ghostii is **disabled by default** (`ghostii[0].articles: 0`).
118
+
103
119
  ## Template-owned schemas
104
120
 
105
121
  Each template under `lib/templates/` owns its own content shape. Templates export:
@@ -199,7 +215,7 @@ marketing-campaigns/{newId}: {
199
215
 
200
216
  Templates add their own fields on top (e.g. classic adds `intro` + `sections`; field-report adds `tldr` + `dateline` + `dispatches`).
201
217
 
202
- **No CTAs in generated content.** The schema intentionally does NOT include section-level CTAs / outbound links. The AI cannot author URLs reliably — it has no browse access to your site and no real source URLs to reference, so any URL it produces is invented. Newsletters are self-contained reads; outbound links come from sponsorship blocks rendered by the template shell (driven by `marketing.beehiiv.content.sponsorships[]`), not from generated section bodies.
218
+ **No AI-authored CTAs in generated content.** The schema intentionally does NOT include section-level CTAs / outbound links. The AI cannot author URLs reliably — it has no browse access to your site and no real source URLs to reference, so any URL it produces is invented. Newsletters are self-contained reads; outbound links come from sponsorship blocks rendered by the template shell (driven by `marketing.beehiiv.content.sponsorships[]`), not from generated section bodies. The **one exception** is the linked-article CTA: when `article.enabled` is on, `section.cta = { label, url }` is injected onto the lead section by code *after* the article is published (a real, verified URL) — never authored by the AI. Both `sectionCard` (MJML) and the markdown renderer render `section.cta` when present.
203
219
 
204
220
  **Beehiiv failure → fallback alert email.** When Beehiiv draft creation fails (e.g. `SEND_API_NOT_ENTERPRISE_PLAN` on the free plan), the generator sends an internal alert email via `sender: 'internal'` (resolves to `alerts@{brandDomain}`) to `brand.contact.email` with:
205
221
  - The failure reason
@@ -249,6 +265,10 @@ marketing: {
249
265
  instructions: '', // free-form AI instructions
250
266
  tone: 'professional',
251
267
  template: 'clean', // clean | editorial | field-report
268
+ article: { // expand the lead section into a linked blog post (Ghostii → admin/post)
269
+ enabled: false,
270
+ author: 'alex-raeburn', // author slug for the linked article
271
+ },
252
272
  theme: { primaryColor, secondaryColor, accentColor, font },
253
273
  sponsorships: [ ... ],
254
274
  },
@@ -270,6 +290,8 @@ marketing: {
270
290
  | Newsletter MJML → HTML | `src/manager/libraries/email/generators/lib/mjml-template.js` |
271
291
  | Newsletter asset host (GitHub upload — PNGs + newsletter.html + newsletter.md + summary.md) | `src/manager/libraries/email/generators/lib/image-host.js` |
272
292
  | Newsletter markdown renderer (programmatic, no AI) | `src/manager/libraries/email/generators/lib/markdown-renderer.js` |
293
+ | Ghostii article engine (writeArticle + publishArticle) | `src/manager/libraries/content/ghostii.js` |
294
+ | Standalone Ghostii article cron (off by default) | `src/manager/events/cron/daily/ghostii-auto-publisher.js` |
273
295
  | Unified AI library | `src/manager/libraries/ai/index.js` (OpenAI + Anthropic via `Manager.AI(assistant).request({ provider, ... })`) |
274
296
  | Notification library | `src/manager/libraries/notification.js` |
275
297
  | SendGrid provider | `src/manager/libraries/email/providers/sendgrid.js` |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backend-manager",
3
- "version": "5.2.16",
3
+ "version": "5.2.17",
4
4
  "description": "Quick tools for developing Firebase functions",
5
5
  "main": "src/manager/index.js",
6
6
  "bin": {
@@ -130,6 +130,14 @@ class BaseCommand {
130
130
  }
131
131
  }
132
132
 
133
+ // Non-interactive environments (CI, agents, piped stdin) have no TTY, so inquirer
134
+ // can't read a keypress — prompting would error or hang. Skip the prompt entirely
135
+ // and auto-confirm the kill so unattended `mgr test` / `mgr emulator` runs proceed.
136
+ if (!process.stdin.isTTY) {
137
+ this.log(chalk.gray(' Non-interactive shell — auto-confirming port cleanup (Y).'));
138
+ return this.killBlockingProcesses(blockedPorts);
139
+ }
140
+
133
141
  // Auto-confirm (Y) after a few seconds of no input so unattended test/dev loops don't
134
142
  // hang. When the timeout fires the prompt is aborted via AbortSignal and we fall back to
135
143
  // the default (true). inquirer owns the cursor and can't live-update its own message, so
@@ -142,11 +150,20 @@ class BaseCommand {
142
150
  { signal: AbortSignal.timeout(AUTO_CONFIRM_SECONDS * 1000) },
143
151
  );
144
152
  } catch (error) {
145
- // AbortPromptError (timeout reached)take the default (true). Re-throw anything else.
146
- if (error?.name !== 'AbortPromptError') {
147
- throw error;
153
+ // Any prompt failurefall back to the safe default (auto-confirm Y) instead of
154
+ // crashing. This covers:
155
+ // - AbortPromptError: the 5s timeout fired (no input).
156
+ // - ExitPromptError / force-close: stdin is present but closed/EOF'd (the
157
+ // case under `mgr test`, agents, and other wrappers that pipe a non-readable
158
+ // stdin). inquirer throws "User force closed the prompt with 0 null" here.
159
+ // Anything unexpected is logged but still defaults to Y so unattended runs proceed.
160
+ const name = error?.name || '';
161
+ const known = name === 'AbortPromptError' || name === 'ExitPromptError';
162
+ if (!known) {
163
+ this.log(chalk.gray(` Prompt unavailable (${name || 'unknown'}) — auto-confirming (Y).`));
164
+ } else {
165
+ this.log(chalk.gray(' No input — auto-confirming (Y).'));
148
166
  }
149
- this.log(chalk.gray(' No input — auto-confirming (Y).'));
150
167
  shouldKill = true;
151
168
  }
152
169
 
@@ -155,6 +172,15 @@ class BaseCommand {
155
172
  return false;
156
173
  }
157
174
 
175
+ return this.killBlockingProcesses(blockedPorts);
176
+ }
177
+
178
+ /**
179
+ * Kill every process on the given blocked ports, then wait for release.
180
+ * @param {object[]} blockedPorts - [{ name, port, processes: [{ pid }] }]
181
+ * @returns {Promise<boolean>} - true if all killed (or already dead), false on failure
182
+ */
183
+ async killBlockingProcesses(blockedPorts) {
158
184
  // Kill ALL processes on each blocked port
159
185
  for (const { name, port, processes } of blockedPorts) {
160
186
  for (const { pid } of processes) {
@@ -23,10 +23,14 @@ npx mgr deploy # deploy to Firebase
23
23
  npx mgr logs:read # read Cloud Functions logs (also: logs:tail to stream)
24
24
  npx mgr firestore:get # read a doc from Firestore (also: firestore:set / :query / :delete)
25
25
  npx mgr auth:get # read an Auth user (also: auth:list / :delete / :set-claims)
26
+ npx mgr install dev # use LOCAL backend-manager source (to test framework edits)
27
+ npx mgr install prod # restore the published backend-manager from npm
26
28
  ```
27
29
 
28
30
  All `npx mgr <cmd>` aliases — `npx bm <cmd>`, `npx bem <cmd>`, `npx backend-manager <cmd>` work too.
29
31
 
32
+ > Editing the BEM framework source while working here? Run `npx mgr install dev` so this project picks up your uncommitted framework changes (it otherwise uses its installed `node_modules/backend-manager`). Run `npx mgr install prod` to switch back.
33
+
30
34
  ## Where things live
31
35
 
32
36
  - `functions/index.js` — entry point. Must call `Manager.init(exports, { ... })` to register all built-in + custom endpoints.
@@ -3,6 +3,8 @@ const powertools = require('node-powertools');
3
3
  const moment = require('moment');
4
4
  const JSON5 = require('json5');
5
5
 
6
+ const { writeArticle, publishArticle } = require('../../../libraries/content/ghostii.js');
7
+
6
8
  const PROMPT = `
7
9
  Company: {brand.brand.name}: {brand.brand.description}
8
10
  Date: {date}
@@ -151,7 +153,11 @@ async function harvest(assistant, settings) {
151
153
  assistant.log('harvest(): Get final content', final);
152
154
 
153
155
  // Request to Ghostii
154
- const article = await requestGhostii(settings, final).catch((e) => e);
156
+ const article = await writeArticle({
157
+ brand: settings.brand,
158
+ description: final,
159
+ links: settings.links,
160
+ }).catch((e) => e);
155
161
  if (article instanceof Error) {
156
162
  assistant.error('harvest(): Error requesting Ghostii', article);
157
163
  break;
@@ -161,7 +167,13 @@ async function harvest(assistant, settings) {
161
167
  assistant.log('harvest(): Article', article);
162
168
 
163
169
  // Upload post to blog
164
- const uploadedPost = await uploadPost(assistant, settings, article).catch((e) => e);
170
+ const uploadedPost = await publishArticle(assistant, {
171
+ brand: settings.brand,
172
+ article,
173
+ id: postId++,
174
+ author: settings.author,
175
+ postPath: 'ghostii',
176
+ }).catch((e) => e);
165
177
  if (uploadedPost instanceof Error) {
166
178
  assistant.error('harvest(): Error uploading post to blog', uploadedPost);
167
179
  break;
@@ -197,51 +209,6 @@ function isURL(url) {
197
209
  }
198
210
  }
199
211
 
200
- function requestGhostii(settings, content) {
201
- return fetch('https://api.ghostii.ai/write/article', {
202
- method: 'post',
203
- timeout: 90000,
204
- tries: 1,
205
- response: 'json',
206
- body: {
207
- backendManagerKey: process.env.BACKEND_MANAGER_KEY,
208
- keywords: [''],
209
- description: content,
210
- insertLinks: true,
211
- headerImageUrl: 'unsplash',
212
- url: settings.brand.brand.url,
213
- sectionQuantity: powertools.random(3, 6, { mode: 'gaussian' }),
214
- feedUrl: `${settings.brand.brand.url}/feeds/posts.json`,
215
- links: settings.links,
216
- },
217
- });
218
- }
219
-
220
- function uploadPost(assistant, settings, article) {
221
- const apiUrl = `https://api.${(settings.brand.brand.url || '').replace(/^https?:\/\//, '')}`;
222
- return fetch(`${apiUrl}/backend-manager/admin/post`, {
223
- method: 'POST',
224
- timeout: 90000,
225
- tries: 1,
226
- response: 'json',
227
- body: {
228
- backendManagerKey: process.env.BACKEND_MANAGER_KEY,
229
- title: article.title,
230
- url: article.title, // This is formatted on the bm_api endpoint
231
- description: article.description,
232
- headerImageURL: article.headerImageUrl,
233
- body: article.body,
234
- id: postId++,
235
- author: settings.author,
236
- categories: article.categories,
237
- tags: article.keywords,
238
- postPath: 'ghostii',
239
- githubUser: settings.brand.github.user,
240
- githubRepo: settings.brand.github.repo,
241
- },
242
- });
243
- }
244
-
245
212
  function extractBodyContent(text, contentType, url) {
246
213
  const parsed = tryParse(text);
247
214
 
@@ -80,10 +80,12 @@ module.exports = async ({ Manager, assistant, libraries }) => {
80
80
  // Run the generator with imageHost forced to 'github' (production cron
81
81
  // path always uploads — that's what "production" means here) and the
82
82
  // campaignId pinned so all assets land in marketing-campaigns/{newId}/'s
83
- // matching folder.
83
+ // matching folder. publishArticle: true so the linked blog post (when
84
+ // config.article.enabled is on) is actually committed to the website repo.
84
85
  const generated = await generators[generator].generate(Manager, assistant, settings, {
85
86
  campaignId: newId,
86
87
  imageHost: 'github',
88
+ publishArticle: true,
87
89
  });
88
90
 
89
91
  if (!generated) {
@@ -395,6 +395,11 @@ OpenAI.prototype.request = function (options) {
395
395
  // Reasons
396
396
  options.reasoning = options.reasoning || undefined;
397
397
 
398
+ // Tools (e.g. built-in web_search). Opt-in — when omitted, no tools are sent
399
+ // and behavior is identical to a plain request.
400
+ options.tools = options.tools || undefined;
401
+ options.toolChoice = options.toolChoice || undefined;
402
+
398
403
  // Format prompt
399
404
  //
400
405
  // Accepts two forms:
@@ -977,6 +982,18 @@ function makeRequest(mode, options, self, promptSegments, message, user, _log) {
977
982
  if (reasoning) {
978
983
  request.body.reasoning = reasoning;
979
984
  }
985
+
986
+ // Only include tools (e.g. web_search) if provided. When present, the
987
+ // response output may contain tool-call items (e.g. web_search_call)
988
+ // alongside the message — the message extractor below already ignores
989
+ // non-message items, so this is purely additive.
990
+ if (Array.isArray(options.tools) && options.tools.length) {
991
+ request.body.tools = options.tools;
992
+
993
+ if (options.toolChoice) {
994
+ request.body.tool_choice = options.toolChoice;
995
+ }
996
+ }
980
997
  }
981
998
 
982
999
  // Request
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Ghostii article engine — shared helpers for AI article generation + publishing.
3
+ *
4
+ * Ghostii (api.ghostii.ai) writes a full blog article (title, body, header image,
5
+ * categories, keywords) from a free-form description. We then publish it to the
6
+ * brand's website repo via the internal `admin/post` route (commits markdown +
7
+ * images to GitHub).
8
+ *
9
+ * Two consumers:
10
+ * - events/cron/daily/ghostii-auto-publisher.js — standalone daily article job
11
+ * - libraries/email/generators/newsletter.js — newsletter-driven linked article
12
+ *
13
+ * Both call writeArticle() then publishArticle(). Kept here as the single source
14
+ * of truth for the Ghostii request shape and the admin/post payload.
15
+ */
16
+ const fetch = require('wonderful-fetch');
17
+ const powertools = require('node-powertools');
18
+
19
+ /**
20
+ * Generate an article via the Ghostii API.
21
+ *
22
+ * @param {object} args
23
+ * @param {object} args.brand - Public brand config ({ brand: { url, ... }, github: { ... } })
24
+ * @param {string} args.description - The article brief / prompt content
25
+ * @param {string[]} [args.links] - Optional links to inject into the article body
26
+ * @returns {Promise<object>} { title, description, body, headerImageUrl, categories, keywords }
27
+ */
28
+ function writeArticle({ brand, description, links }) {
29
+ return fetch('https://api.ghostii.ai/write/article', {
30
+ method: 'post',
31
+ timeout: 90000,
32
+ tries: 1,
33
+ response: 'json',
34
+ body: {
35
+ backendManagerKey: process.env.BACKEND_MANAGER_KEY,
36
+ keywords: [''],
37
+ description: description,
38
+ insertLinks: true,
39
+ headerImageUrl: 'unsplash',
40
+ url: brand.brand.url,
41
+ sectionQuantity: powertools.random(3, 6, { mode: 'gaussian' }),
42
+ feedUrl: `${brand.brand.url}/feeds/posts.json`,
43
+ links: links || [],
44
+ },
45
+ });
46
+ }
47
+
48
+ /**
49
+ * Publish a Ghostii article to the brand's website repo via the admin/post route.
50
+ *
51
+ * @param {object} assistant - BEM assistant instance
52
+ * @param {object} args
53
+ * @param {object} args.brand - Public brand config ({ brand: { url, ... }, github: { user, repo } })
54
+ * @param {object} args.article - The article from writeArticle()
55
+ * @param {number} args.id - Post ID (unix timestamp)
56
+ * @param {string} [args.author] - Author slug (admin/post picks a default if unset)
57
+ * @param {string} [args.postPath='ghostii'] - Sub-folder under _posts/{year}/
58
+ * @returns {Promise<object>} { post, url, slug, path } — `url` is the public blog URL
59
+ */
60
+ async function publishArticle(assistant, { brand, article, id, author, postPath }) {
61
+ const baseUrl = (brand.brand.url || '').replace(/^https?:\/\//, '').replace(/\/$/, '');
62
+ const apiUrl = `https://api.${baseUrl}`;
63
+
64
+ const post = await fetch(`${apiUrl}/backend-manager/admin/post`, {
65
+ method: 'POST',
66
+ timeout: 90000,
67
+ tries: 1,
68
+ response: 'json',
69
+ body: {
70
+ backendManagerKey: process.env.BACKEND_MANAGER_KEY,
71
+ title: article.title,
72
+ url: article.title, // Slugified on the admin/post endpoint
73
+ description: article.description,
74
+ headerImageURL: article.headerImageUrl,
75
+ body: article.body,
76
+ id: id,
77
+ author: author,
78
+ categories: article.categories,
79
+ tags: article.keywords,
80
+ postPath: postPath || 'ghostii',
81
+ githubUser: brand.github.user,
82
+ githubRepo: brand.github.repo,
83
+ },
84
+ });
85
+
86
+ // admin/post returns the resolved `settings` (incl. the slugified `url` and repo `path`).
87
+ // The post template sets no permalink, so the public URL follows the Jekyll/UJM
88
+ // blog convention: {brand.url}/blog/{slug}.
89
+ const slug = post?.url || '';
90
+ const publicUrl = `${(brand.brand.url || '').replace(/\/$/, '')}/blog/${slug}`;
91
+
92
+ return {
93
+ post,
94
+ url: slug ? publicUrl : null,
95
+ slug,
96
+ path: post?.path || null,
97
+ };
98
+ }
99
+
100
+ module.exports = { writeArticle, publishArticle };
@@ -165,6 +165,7 @@ function getBodySections(structure, template) {
165
165
  body: d.dispatch,
166
166
  dataPoints: d.dataPoints,
167
167
  image_prompt: d.image_prompt,
168
+ cta: d.cta, // injected post-generation (e.g. linked-article "Read more")
168
169
  }));
169
170
  }
170
171
 
@@ -175,6 +176,7 @@ function getBodySections(structure, template) {
175
176
  title: s.title,
176
177
  body: s.body,
177
178
  image_prompt: s.image_prompt,
179
+ cta: s.cta, // injected post-generation (e.g. linked-article "Read more")
178
180
  }));
179
181
  }
180
182
 
@@ -236,6 +238,13 @@ function renderSection(section, idx, imagePaths, template) {
236
238
  lines.push(section.body);
237
239
  }
238
240
 
241
+ // CTA (e.g. "Read the full article →") — injected by code post-generation,
242
+ // never authored by the AI. The MJML template renders section.cta via
243
+ // sectionCard; this is the markdown equivalent for the Beehiiv-paste view.
244
+ if (section.cta?.url && section.cta?.label) {
245
+ lines.push(`[${section.cta.label} →](${section.cta.url})`);
246
+ }
247
+
239
248
  return lines.join('\n\n');
240
249
  }
241
250
 
@@ -26,6 +26,8 @@ const { generateSectionImage } = require('./lib/svg-illustrator.js');
26
26
  const { renderNewsletter } = require('./lib/mjml-template.js');
27
27
  const { renderMarkdown } = require('./lib/markdown-renderer.js');
28
28
  const { uploadAssets, RAW_BASE } = require('./lib/image-host.js');
29
+ const { buildPublicConfig } = require('../../../routes/brand/get.js');
30
+ const { writeArticle, publishArticle } = require('../../../libraries/content/ghostii.js');
29
31
 
30
32
  /**
31
33
  * Generate newsletter content from parent server sources.
@@ -45,6 +47,11 @@ const { uploadAssets, RAW_BASE } = require('./lib/image-host.js');
45
47
  * @param {object[]} [opts.sources] - Pre-fetched sources (bypasses parent server claim)
46
48
  * @param {boolean} [opts.skipClaim] - Don't call PUT to mark sources as used
47
49
  * @param {boolean} [opts.skipImages] - Skip SVG/PNG generation (use placeholders)
50
+ * @param {boolean} [opts.publishArticle] - When the linked-article build runs (config.article.enabled),
51
+ * actually COMMIT the post to the website repo via admin/post.
52
+ * When false (default), the article is still generated and its
53
+ * URL computed + injected as the CTA, but nothing is committed.
54
+ * Production cron passes true; the iteration test leaves it false.
48
55
  * @returns {object|null} Updated settings with content filled in, or null if unavailable
49
56
  */
50
57
  async function generate(Manager, assistant, settings, opts = {}) {
@@ -147,11 +154,17 @@ async function generate(Manager, assistant, settings, opts = {}) {
147
154
  || (sources.length === 1 ? sources[0].id : null)
148
155
  || generatePushId();
149
156
 
150
- // 2. SVG illustrations (parallel) + upload PNGs first so we have URLs
151
- // available to embed in the HTML render below.
157
+ // 2. SVG illustrations + (optional) linked blog article run CONCURRENTLY.
158
+ // Both are slow AI calls. The image build produces the section image URLs;
159
+ // the article build expands the lead section into a full blog post and
160
+ // returns its public URL, which we inject as a "Read more" CTA before render.
152
161
  let imagePaths = [];
153
162
 
154
- if (!opts.skipImages) {
163
+ const buildImages = async () => {
164
+ if (opts.skipImages) {
165
+ return;
166
+ }
167
+
155
168
  const images = await Promise.all(
156
169
  structure.sections.map((s) => generateSectionImage({
157
170
  imagePrompt: s.image_prompt,
@@ -191,6 +204,54 @@ async function generate(Manager, assistant, settings, opts = {}) {
191
204
 
192
205
  // Stash images on the return for callers that want to access raw buffers
193
206
  opts._lastImages = images;
207
+ };
208
+
209
+ // The linked-article build is gated by config.article.enabled and needs a lead
210
+ // section to expand. Wrapped so a failure here NEVER blocks the newsletter —
211
+ // it resolves to null and the CTA simply isn't injected.
212
+ //
213
+ // Two phases, independently controllable:
214
+ // - GENERATE: always runs when article.enabled is on. Calls Ghostii to write
215
+ // the article + hero image and computes the public URL it WOULD live at.
216
+ // - PUBLISH: commits the post to the website repo via admin/post. Only when
217
+ // opts.publishArticle is true. The production cron passes true; the
218
+ // iteration test leaves it false (so it exercises generation without
219
+ // committing a real post) unless NEWSLETTER_CREATE_ARTICLE=1 is set.
220
+ //
221
+ // The CTA URL is derived from the article title (same slugify admin/post uses),
222
+ // so the newsletter links correctly even in generate-only mode.
223
+ const wantArticle = !!config.article?.enabled
224
+ && Array.isArray(structure.sections)
225
+ && structure.sections.length > 0;
226
+
227
+ const buildArticle = async () => {
228
+ if (!wantArticle) {
229
+ return null;
230
+ }
231
+
232
+ return buildLinkedArticle({
233
+ Manager,
234
+ assistant,
235
+ brand,
236
+ config,
237
+ structure,
238
+ publish: !!opts.publishArticle,
239
+ }).catch((e) => {
240
+ assistant.error(`Newsletter generator: linked article failed — ${e.message}`);
241
+ return null;
242
+ });
243
+ };
244
+
245
+ const [, articleResult] = await Promise.all([buildImages(), buildArticle()]);
246
+
247
+ // Inject the "Read the full article" CTA onto the lead section BEFORE render.
248
+ // sectionCard (MJML) renders section.cta = { label, url } automatically; the
249
+ // markdown renderer emits it too. The URL is the post's public blog URL —
250
+ // present whether the article was actually published or only generated
251
+ // (derived from the title slug), so the newsletter links correctly either way.
252
+ if (articleResult?.url) {
253
+ structure.sections[0].cta = { label: 'Read the full article', url: articleResult.url };
254
+ assistant.log(`Newsletter generator: linked article ${articleResult.published ? 'published' : 'generated (not published)'} — ${articleResult.url}`);
194
255
  }
195
256
 
196
257
  // 3. MJML → HTML
@@ -342,6 +403,11 @@ async function generate(Manager, assistant, settings, opts = {}) {
342
403
  })),
343
404
  },
344
405
  totals: aggregateTotals(filterMeta, structure._meta, opts._lastImages),
406
+ // Linked blog article (when config.article.enabled is on). null if disabled or failed.
407
+ // `published` is false when the article was generated but not committed (e.g. test mode).
408
+ article: articleResult
409
+ ? { url: articleResult.url, slug: articleResult.slug, path: articleResult.path, published: !!articleResult.published }
410
+ : null,
345
411
  };
346
412
 
347
413
  // Public asset URLs — stamped onto the generated campaign doc by the cron
@@ -358,6 +424,7 @@ async function generate(Manager, assistant, settings, opts = {}) {
358
424
  summaryUrl,
359
425
  imageUrls: imagePaths,
360
426
  beehiivPostId,
427
+ articleUrl: articleResult?.url || null, // linked blog post (config.article.enabled), null otherwise
361
428
  tags: Array.isArray(structure.tags) ? structure.tags : [],
362
429
  } : null;
363
430
 
@@ -375,9 +442,87 @@ async function generate(Manager, assistant, settings, opts = {}) {
375
442
  images: opts._lastImages || [], // image buffers for the iteration test to persist locally
376
443
  assets, // GitHub asset URLs (folder, html, md, summary, images) — null in local mode
377
444
  meta, // per-step provider/model/cost/timing telemetry
445
+ article: articleResult || null, // full linked-article result { url, slug, path, published, article: {title, body, headerImageUrl, ...} } — null when disabled/failed
378
446
  };
379
447
  }
380
448
 
449
+ /**
450
+ * Expand the newsletter's lead section into a full blog article via Ghostii and
451
+ * (optionally) publish it to the brand's website repo. Returns the post's public
452
+ * URL so the newsletter can link to it via a "Read the full article" CTA.
453
+ *
454
+ * The lead section (structure.sections[0]) is the same topic the newsletter
455
+ * leads with, so the resulting article is the long-form version.
456
+ *
457
+ * Two phases:
458
+ * - GENERATE (always): Ghostii writes the article + hero image. We then compute
459
+ * the public URL the post WOULD live at, using the same title→slug rule
460
+ * admin/post applies. This URL is valid for the CTA whether or not we publish.
461
+ * - PUBLISH (only when `publish`): commit the post to the website repo via
462
+ * admin/post (GitHub). When `publish` is false, nothing is committed.
463
+ *
464
+ * Gated upstream by config.article.enabled. Any failure is caught by the caller
465
+ * and the CTA is simply omitted — the newsletter never depends on this.
466
+ *
467
+ * @param {object} args
468
+ * @param {object} args.Manager
469
+ * @param {object} args.assistant
470
+ * @param {object} args.brand - { id, name, url, ... }
471
+ * @param {object} args.config - marketing.beehiiv.content (tone, instructions, article.author)
472
+ * @param {object} args.structure - newsletter structure (sections[0] is the lead)
473
+ * @param {boolean} [args.publish] - Commit the post to GitHub via admin/post. Default false.
474
+ * @returns {Promise<{url, slug, path, published}|null>}
475
+ */
476
+ async function buildLinkedArticle({ Manager, assistant, brand, config, structure, publish }) {
477
+ const lead = structure.sections[0] || {};
478
+ const publicConfig = buildPublicConfig(Manager.config);
479
+
480
+ // Build the article brief from the lead section, folded with the shared
481
+ // editorial steer (tone + instructions) so the blog post matches the
482
+ // newsletter's voice. Ghostii expands this into a full article + hero image.
483
+ const briefLines = [
484
+ `Company: ${brand?.name || ''}: ${brand?.description || ''}`,
485
+ config?.tone ? `Tone: ${config.tone}` : '',
486
+ config?.instructions ? `Instructions: ${config.instructions}` : '',
487
+ '',
488
+ `Write a full blog article expanding on this topic:`,
489
+ `Title: ${lead.title || ''}`,
490
+ `Summary: ${lead.body || ''}`,
491
+ ].filter(Boolean).join('\n');
492
+
493
+ assistant.log(`Newsletter generator: building linked article for "${lead.title}"`);
494
+
495
+ // Phase 1 — GENERATE (always)
496
+ const article = await writeArticle({
497
+ brand: publicConfig,
498
+ description: briefLines,
499
+ });
500
+
501
+ // Compute the public URL the post WOULD live at. admin/post slugifies the
502
+ // title (after stripping a `blog/` prefix that titles never have), so we
503
+ // mirror that here to derive the same slug without needing to publish.
504
+ const slug = Manager.Utilities().slugify(article.title || '');
505
+ const url = slug
506
+ ? `${(publicConfig.brand?.url || '').replace(/\/$/, '')}/blog/${slug}`
507
+ : null;
508
+
509
+ // Phase 2 — PUBLISH (gated). Commit to the website repo only when asked.
510
+ if (!publish) {
511
+ assistant.log(`Newsletter generator: article generated but NOT published (publish=false) — would live at ${url}`);
512
+ return { url, slug, path: null, published: false, article };
513
+ }
514
+
515
+ const result = await publishArticle(assistant, {
516
+ brand: publicConfig,
517
+ article,
518
+ id: Math.round(Date.now() / 1000),
519
+ author: config?.article?.author,
520
+ postPath: 'ghostii',
521
+ });
522
+
523
+ return { url: result.url || url, slug: result.slug || slug, path: result.path, published: true, article };
524
+ }
525
+
381
526
  /**
382
527
  * Send an internal alert email when Beehiiv draft creation fails so the
383
528
  * brand team knows there's a ready newsletter waiting for manual upload.
@@ -1,4 +1,5 @@
1
1
  const moment = require('moment');
2
+ const _ = require('lodash');
2
3
  const { inferContact } = require('../../../libraries/infer-contact.js');
3
4
  const { validate: validateEmail, isDisposable } = require('../../../libraries/email/validation.js');
4
5
 
@@ -61,7 +62,14 @@ module.exports = async ({ assistant, user, settings, libraries }) => {
61
62
  // 4. Gather all data, then write once
62
63
  const email = user.auth.email;
63
64
  const inferred = await inferUserContact(assistant, email);
64
- const userRecord = buildUserRecord(assistant, settings, inferred, authUser.metadata.creationTime, userDoc);
65
+ const userRecord = buildUserRecord(assistant, {
66
+ settings,
67
+ inferred,
68
+ uid,
69
+ email,
70
+ creationTime: authUser.metadata.creationTime,
71
+ existingDoc: userDoc,
72
+ });
65
73
 
66
74
  assistant.log(`signup(): Writing user record for ${uid}`, userRecord);
67
75
 
@@ -111,16 +119,43 @@ async function pollForUserDoc(assistant, uid) {
111
119
  }
112
120
 
113
121
  /**
114
- * Build the full user record: client details, attribution, and inferred contact
122
+ * Build the complete user record to write at signup completion.
123
+ *
124
+ * Returns the WHOLE merged document (written without {merge}), layered deepest-first:
125
+ * 1. Manager.User() full schema shape — guarantees every leaf exists (so a doc created by a
126
+ * partial path, e.g. onCreate never firing, still ends up schema-complete).
127
+ * 2. the existing doc — real values win over the schema defaults, so we never clobber the
128
+ * user's api keys, subscription, roles, affiliate.code, or any custom/non-standard fields.
129
+ * 3. the signup data — attribution / activity / consent / flags / personal we own at signup
130
+ * land on top.
131
+ *
132
+ * Why a full deep-merge instead of `.set(partial, {merge:true})`: Firestore's merge REPLACES a
133
+ * map field rather than deep-merging it, so writing a partial `attribution` flattened onCreate's
134
+ * full attribution object and the OMEGA migration had to re-add every leaf on every signup.
135
+ * Deep-merging in JS and writing the whole doc avoids that entirely.
115
136
  */
116
- function buildUserRecord(assistant, settings, inferred, creationTime, existingDoc) {
137
+ function buildUserRecord(assistant, { settings, inferred, uid, email, creationTime, existingDoc }) {
117
138
  const Manager = assistant.Manager;
118
- const attribution = settings.attribution;
119
139
 
120
- const record = {
121
- flags: {
122
- signupProcessed: true,
123
- },
140
+ // Inferred name/company (from AI/regex on the email) — only set when present.
141
+ const personal = {};
142
+ if (inferred?.firstName || inferred?.lastName) {
143
+ personal.name = {
144
+ ...(inferred.firstName ? { first: inferred.firstName } : {}),
145
+ ...(inferred.lastName ? { last: inferred.lastName } : {}),
146
+ };
147
+ }
148
+ if (inferred?.company) {
149
+ personal.company = { name: inferred.company };
150
+ }
151
+
152
+ // Layer 1: full schema shape (every leaf present with defaults).
153
+ const schemaShape = Manager.User({ auth: { uid, email } }).properties;
154
+
155
+ // Layer 3: the data signup owns.
156
+ const signupData = {
157
+ auth: { uid, email },
158
+ flags: { signupProcessed: true },
124
159
  activity: {
125
160
  ...settings.context,
126
161
  geolocation: {
@@ -132,37 +167,24 @@ function buildUserRecord(assistant, settings, inferred, creationTime, existingDo
132
167
  ...(settings.context?.client || {}),
133
168
  },
134
169
  },
135
- attribution: attribution || {},
170
+ attribution: settings.attribution || {},
136
171
  consent: buildConsentRecord(assistant, settings.consent, creationTime, existingDoc?.consent),
137
172
  metadata: Manager.Metadata().set({ tag: 'user/signup' }),
173
+ ...(Object.keys(personal).length ? { personal } : {}),
138
174
  };
139
175
 
140
- // Stamp metadata.created from Firebase Auth's creationTime so it matches Auth's canonical
141
- // value. Normally onCreate sets this, but if onCreate didn't fire this merge write is the
142
- // doc's first creation — without this the doc lands with no created date and the OMEGA
143
- // migration has to backfill it. Idempotent: when onCreate already wrote it, this matches.
176
+ // metadata.created from Auth's creationTime (canonical), matching onCreate + the migration SSOT.
144
177
  if (creationTime) {
145
178
  const createdDate = new Date(creationTime);
146
- record.metadata.created = {
179
+ signupData.metadata.created = {
147
180
  timestamp: createdDate.toISOString(),
148
181
  timestampUNIX: Math.round(createdDate.getTime() / 1000),
149
182
  };
150
183
  }
151
184
 
152
- // Add inferred name/company if available
153
- if (inferred) {
154
- record.personal = {
155
- ...(inferred.firstName || inferred.lastName ? {
156
- name: {
157
- ...(inferred.firstName ? { first: inferred.firstName } : {}),
158
- ...(inferred.lastName ? { last: inferred.lastName } : {}),
159
- },
160
- } : {}),
161
- ...(inferred.company ? { company: { name: inferred.company } } : {}),
162
- };
163
- }
164
-
165
- return record;
185
+ // Deep-merge: schema (base) existing doc (real values win) ← signup data (owned fields win).
186
+ // _.merge mutates its first arg, so start from a fresh object.
187
+ return _.merge({}, schemaShape, existingDoc || {}, signupData);
166
188
  }
167
189
 
168
190
  /**
@@ -364,6 +386,7 @@ function sendWelcomeEmails(assistant, uid, firstName) {
364
386
  }
365
387
 
366
388
  sendWelcomeEmail(assistant, uid, firstName).catch(e => assistant.error('signup(): sendWelcomeEmail failed:', e));
389
+ sendDiscountNudgeEmail(assistant, uid, firstName).catch(e => assistant.error('signup(): sendDiscountNudgeEmail failed:', e));
367
390
  sendCheckupEmail(assistant, uid, firstName).catch(e => assistant.error('signup(): sendCheckupEmail failed:', e));
368
391
  sendFeedbackEmail(assistant, uid).catch(e => assistant.error('signup(): sendFeedbackEmail failed:', e));
369
392
  }
@@ -411,6 +434,68 @@ Thank you for choosing **${Manager.config.brand.name}**. Here's to new beginning
411
434
  });
412
435
  }
413
436
 
437
+ /**
438
+ * Send discount-nudge email (24 hours after signup)
439
+ *
440
+ * A warm, personal check-in that offers a discount code in exchange for a reply.
441
+ * Scheduled fire-and-forget via sendAt (same pattern as checkup/feedback) — there is
442
+ * intentionally no premium check at send time, so a user who upgrades within 24h may
443
+ * still receive it. The copy is deliberately worded as a friendly thank-you (not "you
444
+ * haven't upgraded") so it reads fine regardless of the recipient's current plan.
445
+ *
446
+ * The reply itself is the goal: replies are a strong positive sender-reputation signal,
447
+ * and a real human check-in lands in the Primary tab rather than Promotions. Inbound
448
+ * reply handling (auto-issuing the code) is out of scope here — replies are handled
449
+ * separately.
450
+ *
451
+ * Subject is personalized with the recipient's first name when available, and uses
452
+ * intrigue framing ("something for you 🎁") rather than spam-trigger words ("free",
453
+ * "claim", "bonus") to protect deliverability.
454
+ */
455
+ function sendDiscountNudgeEmail(assistant, uid, firstName) {
456
+ const Manager = assistant.Manager;
457
+ const mailer = Manager.Email(assistant);
458
+ const greeting = firstName ? `Hey ${firstName}` : 'Hey there';
459
+ const subject = firstName
460
+ ? `${firstName}, I've got something for you 🎁`
461
+ : `I've got something for you 🎁`;
462
+
463
+ return mailer.send({
464
+ to: uid,
465
+ sender: 'hello',
466
+ categories: ['engagement/discount-nudge'],
467
+ subject: subject,
468
+ template: 'default',
469
+ copy: false,
470
+ sendAt: moment().add(24, 'hours').unix(),
471
+ data: {
472
+ email: {
473
+ preview: `Just checking in from ${Manager.config.brand.name} — and I've got a little thank-you for you.`,
474
+ },
475
+ body: {
476
+ title: `How's it going?`,
477
+ message: `${greeting},
478
+
479
+ It's Ian, the founder of **${Manager.config.brand.name}**.
480
+
481
+ As a thank-you for giving us a try, I'd love to send you a code for a **premium upgrade**. **Just reply to this email** and I'll get one over to you.
482
+
483
+ I read every reply, so if you have any questions, feedback, or there's anything I can help with, this is the place. Looking forward to hearing from you!`,
484
+ },
485
+ signoff: {
486
+ type: 'personal',
487
+ name: 'Ian Wiedenman, CEO',
488
+ url: `https://ianwiedenman.com?utm_source=discount-nudge-email&utm_medium=email&utm_campaign=${Manager.config.brand.id}`,
489
+ urlText: '@ianwieds',
490
+ },
491
+ },
492
+ })
493
+ .then((result) => {
494
+ assistant.log('sendDiscountNudgeEmail(): Success', result.status);
495
+ return result;
496
+ });
497
+ }
498
+
414
499
  /**
415
500
  * Send checkup email (7 days after signup)
416
501
  */
@@ -205,6 +205,19 @@ const STATIC_ACCOUNTS = {
205
205
  subscription: { product: { id: 'basic' }, status: 'active' },
206
206
  },
207
207
  },
208
+ // Used to verify buildUserRecord's layered deep-merge: the test seeds this account's doc with
209
+ // real values (api keys, paid subscription, admin role, a custom non-schema field) + a partial
210
+ // attribution, fires /user/signup, and asserts the merge PRESERVES those real/custom values
211
+ // while still filling every schema leaf and applying the signup data on top.
212
+ 'signup-merge': {
213
+ id: 'signup-merge',
214
+ uid: '_test-signup-merge',
215
+ email: '_test.signup-merge@{domain}',
216
+ properties: {
217
+ roles: {},
218
+ subscription: { product: { id: 'basic' }, status: 'active' },
219
+ },
220
+ },
208
221
  };
209
222
 
210
223
  /**
@@ -153,6 +153,10 @@
153
153
  instructions: '', // free-form text passed to the AI ("focus on X", "avoid Y", brand voice notes)
154
154
  tone: 'professional', // 'professional', 'casual', 'actionable', 'witty', etc. — passed to AI prompt
155
155
  template: 'clean', // 'clean' | 'editorial' | 'field-report' — layout template (each owns its own content shape and aesthetic)
156
+ article: {
157
+ enabled: false, // when true, the newsletter's lead section is expanded into a full blog post via Ghostii, published to the website repo, and linked from the newsletter with a "Read the full article" CTA
158
+ author: null, // author slug for the linked article (Ghostii/admin-post picks a default if unset)
159
+ },
156
160
  theme: {
157
161
  primaryColor: '#5B5BFF', // accent color: buttons, links, brand text
158
162
  secondaryColor: '#1E1E2A', // body text color
@@ -192,9 +196,12 @@
192
196
  appId: '1:123:web:456',
193
197
  measurementId: 'G-0123456789',
194
198
  },
199
+ // Standalone Ghostii article publisher (daily cron). OPT-IN: disabled by
200
+ // default (articles: 0). Set articles >= 1 to auto-publish independent blog
201
+ // posts. For newsletter-linked articles, use marketing.beehiiv.content.article.enabled instead.
195
202
  ghostii: [
196
203
  {
197
- articles: 1,
204
+ articles: 0,
198
205
  sources: [
199
206
  '$app',
200
207
  // Add more sources here
@@ -204,7 +211,7 @@
204
211
  ],
205
212
  prompt: '',
206
213
  chance: 1.0,
207
- author: 'alex-raeburn',
214
+ author: null,
208
215
  // app: 'other-app-id', // Optional: target a different app
209
216
  // appUrl: 'https://api.otherapp.com', // Required if app is set (fetches /backend-manager/app)
210
217
  }
@@ -44,8 +44,16 @@
44
44
  * NEWSLETTER_PEEK=1 Fetch + list ready sources, do not claim, exit.
45
45
  * NEWSLETTER_SOURCE_ID=<id> Generate from one specific source WITHOUT claiming it.
46
46
  * NEWSLETTER_LIMIT=10 Sources per category for PEEK (default 10).
47
+ * NEWSLETTER_CLAIM=1 CLAIM the fetched sources (claimFor=brandId), consuming them so the
48
+ * real newsletter won't reuse them. OFF by default — the test fetches
49
+ * without claiming so runs are repeatable and non-destructive.
47
50
  * NEWSLETTER_RELEASE=1 Reset locally-tracked claimed sources back to 'ready'.
48
51
  * NEWSLETTER_NO_IMAGES=1 Skip SVG/PNG generation (fast iteration on copy only).
52
+ * NEWSLETTER_CREATE_ARTICLE=1 PUBLISH the linked blog article to the website repo (Ghostii → admin/post → GitHub).
53
+ * The article is always GENERATED when the brand's
54
+ * marketing.beehiiv.content.article.enabled is on (exercises the Ghostii write +
55
+ * URL/CTA path); this flag only controls whether it's actually committed.
56
+ * OFF by default — a newsletter test run never commits a real post.
49
57
  * NEWSLETTER_PROVIDER_STRUCTURE=X Override structure provider (openai|anthropic).
50
58
  * NEWSLETTER_PROVIDER_SVG=X Override SVG provider (openai|anthropic).
51
59
  * NEWSLETTER_CAMPAIGN_ID=<id> Override the auto-generated campaign ID (folder name in newsletter-assets).
@@ -345,13 +353,20 @@ module.exports = {
345
353
  return;
346
354
  }
347
355
 
348
- // --- Fetch sources (real claim) ---
356
+ // --- Fetch sources ---
357
+ // By DEFAULT the test does NOT claim sources — it fetches them without
358
+ // claimFor, so they stay 'ready' and available for the real newsletter.
359
+ // This keeps test runs repeatable and non-destructive (a test should never
360
+ // silently consume production resources). Set NEWSLETTER_CLAIM=1 to exercise
361
+ // the real claim/consume path (then use NEWSLETTER_RELEASE=1 to put them back).
362
+ const claim = !!env.NEWSLETTER_CLAIM;
349
363
  const sources = await fetchSourcesForRun({
350
364
  parentUrl,
351
365
  newsletterConfig,
352
366
  brandId: config.brand?.id,
353
367
  sourceId: env.NEWSLETTER_SOURCE_ID,
354
368
  key: env.BACKEND_MANAGER_KEY,
369
+ claim,
355
370
  });
356
371
 
357
372
  // Environmental precondition: the parent server must have ready sources in
@@ -361,8 +376,10 @@ module.exports = {
361
376
  return skip('No ready newsletter sources available on parent server (environmental)');
362
377
  }
363
378
 
364
- // Track claimed IDs for later --release-all
365
- appendClaimed(claimedFile, sources.map((s) => s.id));
379
+ // Track claimed IDs for later --release-all (only when we actually claimed)
380
+ if (claim && !env.NEWSLETTER_SOURCE_ID) {
381
+ appendClaimed(claimedFile, sources.map((s) => s.id));
382
+ }
366
383
 
367
384
  // Force `beehiiv.enabled: true` and inject the per-run newsletter config
368
385
  // overrides onto Manager.config. The iteration test IS the explicit trigger
@@ -382,12 +399,18 @@ module.exports = {
382
399
  // --- Run the production generator with the local-persist image hook ---
383
400
  const generator = require('../../src/manager/libraries/email/generators/newsletter.js');
384
401
 
385
- // EXTENDED mode is a MIRROR of the production cron no escape hatches.
402
+ // EXTENDED mode mirrors the production cron's newsletter side effects:
386
403
  // GH upload always happens (PNGs + newsletter.html), Beehiiv draft upload
387
404
  // always happens (governed inside newsletter.js by beehiiv.enabled, which
388
405
  // we force true above). If you don't want the side effects, run fixture
389
406
  // mode instead.
390
407
  //
408
+ // The ONE deliberate exception is PUBLISHING the linked blog article: the
409
+ // article is still generated (so the test exercises the Ghostii write + CTA
410
+ // path), but it's NOT committed to the website repo unless you opt in with
411
+ // NEWSLETTER_CREATE_ARTICLE=1 (publishArticle below). Committing a real post
412
+ // is out of scope for a routine newsletter test.
413
+ //
391
414
  // persistImage is a side-effect callback that writes PNG+SVG to runDir for
392
415
  // local preview / debug. Its return value is ignored when imageHost: 'github'
393
416
  // because the generator uses the uploaded CDN URLs in the rendered HTML.
@@ -416,6 +439,11 @@ module.exports = {
416
439
  sources,
417
440
  skipClaim: true, // We manage the claim/release lifecycle ourselves
418
441
  skipImages: !!env.NEWSLETTER_NO_IMAGES,
442
+ // The article is GENERATED whenever the brand's config.article.enabled is on
443
+ // (exercises the Ghostii write + URL/CTA path), but only PUBLISHED to the
444
+ // website repo when you opt in with NEWSLETTER_CREATE_ARTICLE=1. Default
445
+ // test run generates but does not commit a real post.
446
+ publishArticle: !!env.NEWSLETTER_CREATE_ARTICLE,
419
447
  // Local disk persistence runs unconditionally (for preview/debug)
420
448
  persistImage,
421
449
  // EXTENDED always uploads to GitHub — mirrors production cron exactly
@@ -444,10 +472,37 @@ module.exports = {
444
472
  assets: result.assets || null,
445
473
  }, null, 2));
446
474
 
475
+ // Linked blog article (when content.article.enabled). Save the full Ghostii
476
+ // output + the computed URL so you can review what would be published — both
477
+ // the raw JSON and a readable markdown view of the article body.
478
+ if (result.article?.article) {
479
+ const a = result.article.article;
480
+ jetpack.write(path.join(runDir, 'article.json'), JSON.stringify(result.article, null, 2));
481
+ jetpack.write(path.join(runDir, 'article.md'), [
482
+ `# ${a.title || ''}`,
483
+ '',
484
+ `> ${a.description || ''}`,
485
+ '',
486
+ `**URL:** ${result.article.url || '(none)'}`,
487
+ `**Published:** ${result.article.published ? 'yes' : 'no (generate-only)'}`,
488
+ a.headerImageUrl ? `**Header image:** ${a.headerImageUrl}` : '',
489
+ a.categories?.length ? `**Categories:** ${a.categories.join(', ')}` : '',
490
+ a.keywords?.length ? `**Keywords:** ${a.keywords.join(', ')}` : '',
491
+ '',
492
+ '---',
493
+ '',
494
+ a.body || '',
495
+ ].filter(line => line !== undefined).join('\n'));
496
+ }
497
+
447
498
  console.log(`\nNewsletter preview written: ${previewPath}`);
448
499
  console.log(`Subject: ${result.subject}`);
449
500
  console.log(`Preheader: ${result.preheader}`);
450
501
  console.log(`Sections: ${result.structure.sections.length}`);
502
+ if (result.article?.article) {
503
+ console.log(`Article: "${result.article.article.title}" → ${result.article.url} (${result.article.published ? 'published' : 'generate-only'})`);
504
+ console.log(` ${path.join(runDir, 'article.md')}`);
505
+ }
451
506
  if (result.meta?.totals) {
452
507
  const t = result.meta.totals;
453
508
  console.log(`\nRun summary:`);
@@ -606,13 +661,14 @@ async function peekSources({ parentUrl, categories, limit, key }) {
606
661
  /**
607
662
  * Fetch sources for an actual generation run. Either:
608
663
  * - A specific source by id — preview only, NO claim (iterate repeatedly on the same source)
609
- * - Or N per category (claims them atomically for the brand)
664
+ * - Or N per category. Fetches WITHOUT claiming by default (claim=false), so runs are
665
+ * repeatable; pass claim=true (NEWSLETTER_CLAIM=1) to atomically claim/consume them.
610
666
  *
611
667
  * When NEWSLETTER_SOURCE_ID is set, we look the source up in any status
612
668
  * (ready, claimed, used) so you can keep iterating on it across runs without
613
669
  * the parent server's claim mechanism marking it consumed.
614
670
  */
615
- async function fetchSourcesForRun({ parentUrl, newsletterConfig, brandId, sourceId, key }) {
671
+ async function fetchSourcesForRun({ parentUrl, newsletterConfig, brandId, sourceId, key, claim = false }) {
616
672
  if (sourceId) {
617
673
  // Peek across ALL ready sources (no claim). Search broadly first, then
618
674
  // fall back to any-status if needed. We never call claimFor with sourceId
@@ -657,11 +713,18 @@ async function fetchSourcesForRun({ parentUrl, newsletterConfig, brandId, source
657
713
 
658
714
  const all = [];
659
715
  for (const category of categories) {
716
+ // claimFor is what tells the parent server to mark sources consumed. Omit it
717
+ // (claim=false) to fetch the same sources without claiming them.
718
+ const query = { category, limit: 3, backendManagerKey: key };
719
+ if (claim) {
720
+ query.claimFor = brandId;
721
+ }
722
+
660
723
  const data = await fetch(`${parentUrl}/newsletter-sources`, {
661
724
  method: 'get',
662
725
  response: 'json',
663
726
  timeout: 15000,
664
- query: { category, limit: 3, claimFor: brandId, backendManagerKey: key },
727
+ query,
665
728
  });
666
729
  all.push(...(data.sources || []));
667
730
  }
@@ -436,6 +436,85 @@ module.exports = {
436
436
  },
437
437
  },
438
438
 
439
+ // --- buildUserRecord layered deep-merge tests ---
440
+ // The signup write must: (a) fill every schema leaf so the doc is complete (no migration
441
+ // churn), (b) PRESERVE existing real values (api keys, subscription, roles, affiliate.code,
442
+ // custom non-schema fields), and (c) apply the signup data on top — without Firestore's
443
+ // map-replace wiping nested data. These tests seed adversarial existing state and verify.
444
+ {
445
+ name: 'merge-preserves-existing-and-fills-schema',
446
+ async run({ http, firestore, assert, accounts }) {
447
+ const uid = accounts['signup-merge'].uid;
448
+
449
+ // Seed real/custom state that the signup write must NOT clobber, plus a deliberately
450
+ // PARTIAL attribution (only affiliate.code) to prove leaves get filled, not replaced-away.
451
+ await firestore.set(`users/${uid}`, {
452
+ api: { clientId: 'REAL-CLIENT-ID', privateKey: 'REAL-PRIVATE-KEY' },
453
+ affiliate: { code: 'REALAFFILIATECODE', referrals: [{ uid: 'someone' }] },
454
+ subscription: { product: { id: 'pro', name: 'Pro' }, status: 'active' },
455
+ roles: { admin: true, betaTester: false, developer: false },
456
+ attribution: { affiliate: { code: 'PARTIALONLY' } },
457
+ myCustomIntegration: { slackWebhook: 'https://hooks.slack.com/services/XXX' },
458
+ }, { merge: true });
459
+
460
+ const signupResponse = await http.as('signup-merge').post('user/signup', {
461
+ consent: { legal: { granted: true, text: 'I agree.' }, marketing: { granted: true, text: 'Updates please.' } },
462
+ attribution: { utm: { tags: { utm_source: 'newsletter' } } },
463
+ });
464
+ assert.isSuccess(signupResponse, `Signup should succeed: ${JSON.stringify(signupResponse, null, 2)}`);
465
+
466
+ const doc = await firestore.get(`users/${uid}`);
467
+
468
+ // (b) Existing real values must survive untouched — NOT regenerated/reset by the schema layer.
469
+ assert.equal(doc?.api?.clientId, 'REAL-CLIENT-ID', 'api.clientId must be preserved (not regenerated)');
470
+ assert.equal(doc?.api?.privateKey, 'REAL-PRIVATE-KEY', 'api.privateKey must be preserved (not regenerated)');
471
+ assert.equal(doc?.affiliate?.code, 'REALAFFILIATECODE', 'affiliate.code must be preserved (not regenerated)');
472
+ assert.equal(doc?.subscription?.product?.id, 'pro', 'subscription must be preserved (not reset to basic)');
473
+ assert.equal(doc?.roles?.admin, true, 'roles.admin must be preserved (not reset to false)');
474
+
475
+ // (b) Custom non-schema field must survive the merge.
476
+ assert.equal(doc?.myCustomIntegration?.slackWebhook, 'https://hooks.slack.com/services/XXX', 'custom non-schema field must survive');
477
+
478
+ // (a) Every attribution leaf must be present (the bug: partial write flattened the map).
479
+ assert.hasProperty(doc, 'attribution.affiliate.code', 'attribution.affiliate.code must exist');
480
+ assert.hasProperty(doc, 'attribution.affiliate.url', 'attribution.affiliate.url must exist (filled)');
481
+ assert.hasProperty(doc, 'attribution.affiliate.page', 'attribution.affiliate.page must exist (filled)');
482
+ assert.hasProperty(doc, 'attribution.affiliate.timestamp', 'attribution.affiliate.timestamp must exist (filled)');
483
+ assert.hasProperty(doc, 'attribution.utm.url', 'attribution.utm.url must exist (filled)');
484
+ assert.hasProperty(doc, 'attribution.utm.page', 'attribution.utm.page must exist (filled)');
485
+ assert.hasProperty(doc, 'attribution.utm.timestamp', 'attribution.utm.timestamp must exist (filled)');
486
+ // filled leaves should be null, not undefined/missing
487
+ assert.equal(doc?.attribution?.affiliate?.url, null, 'unset attribution leaf should be null');
488
+
489
+ // (c) Signup data applied on top.
490
+ assert.equal(doc?.attribution?.affiliate?.code, 'PARTIALONLY', 'pre-existing affiliate.code preserved (signup did not send one)');
491
+ assert.equal(doc?.attribution?.utm?.tags?.utm_source, 'newsletter', 'signup utm tag applied');
492
+ assert.equal(doc?.flags?.signupProcessed, true, 'flags.signupProcessed set true');
493
+ assert.equal(doc?.consent?.legal?.status, 'granted', 'consent applied');
494
+ },
495
+ },
496
+ {
497
+ name: 'merge-fills-all-leaves-on-schema-complete-doc',
498
+ async run({ http, firestore, assert, accounts }) {
499
+ // Sanity: after signup, the doc must contain the full set of top-level schema sections,
500
+ // so a subsequent migration finds NOTHING to backfill. Reuses the signup-merge account
501
+ // (already processed above → re-fire is rejected, but the doc from the prior test is the
502
+ // artifact we assert against; this test just validates that doc's completeness).
503
+ const uid = accounts['signup-merge'].uid;
504
+ const doc = await firestore.get(`users/${uid}`);
505
+
506
+ for (const section of ['auth', 'roles', 'flags', 'affiliate', 'metadata', 'activity', 'api', 'personal', 'attribution', 'consent', 'subscription']) {
507
+ assert.hasProperty(doc, section, `doc must have top-level '${section}' section after signup`);
508
+ }
509
+ // Nested completeness spot-checks across the sections signup writes.
510
+ assert.hasProperty(doc, 'activity.geolocation.ip', 'activity.geolocation.ip must exist');
511
+ assert.hasProperty(doc, 'activity.client.userAgent', 'activity.client.userAgent must exist');
512
+ assert.hasProperty(doc, 'personal.name.first', 'personal.name.first must exist');
513
+ assert.hasProperty(doc, 'consent.marketing.revokedAt.source', 'consent.marketing.revokedAt.source must exist');
514
+ assert.hasProperty(doc, 'metadata.created.timestampUNIX', 'metadata.created.timestampUNIX must exist');
515
+ },
516
+ },
517
+
439
518
  // --- Auth rejection test (at end per convention) ---
440
519
  {
441
520
  name: 'unauthenticated-rejected',