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 +13 -0
- package/CLAUDE.md +3 -3
- package/README.md +1 -0
- package/docs/admin-post-route.md +2 -0
- package/docs/ai-library.md +17 -0
- package/docs/marketing-campaigns.md +23 -1
- package/package.json +1 -1
- package/src/cli/commands/base-command.js +30 -4
- package/src/defaults/CLAUDE.md +4 -0
- package/src/manager/events/cron/daily/ghostii-auto-publisher.js +14 -47
- package/src/manager/events/cron/daily/marketing-newsletter-generate.js +3 -1
- package/src/manager/libraries/ai/providers/openai.js +17 -0
- package/src/manager/libraries/content/ghostii.js +100 -0
- package/src/manager/libraries/email/generators/lib/markdown-renderer.js +9 -0
- package/src/manager/libraries/email/generators/newsletter.js +148 -3
- package/src/manager/routes/user/signup/post.js +113 -28
- package/src/test/test-accounts.js +13 -0
- package/templates/backend-manager-config.json +9 -2
- package/test/marketing/newsletter-generate.js +70 -7
- package/test/routes/user/signup.js +79 -0
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
|
|
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
|
package/docs/admin-post-route.md
CHANGED
|
@@ -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., ``)
|
package/docs/ai-library.md
CHANGED
|
@@ -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
|
@@ -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
|
-
//
|
|
146
|
-
|
|
147
|
-
|
|
153
|
+
// Any prompt failure → fall 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) {
|
package/src/defaults/CLAUDE.md
CHANGED
|
@@ -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
|
|
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
|
|
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 (
|
|
151
|
-
//
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
179
|
+
signupData.metadata.created = {
|
|
147
180
|
timestamp: createdDate.toISOString(),
|
|
148
181
|
timestampUNIX: Math.round(createdDate.getTime() / 1000),
|
|
149
182
|
};
|
|
150
183
|
}
|
|
151
184
|
|
|
152
|
-
//
|
|
153
|
-
|
|
154
|
-
|
|
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:
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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',
|