backend-manager 5.9.5 → 5.9.6
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/package.json +8 -1
- package/src/test/fixtures/firebase-project/.temp/newsletter/run-2026-06-11T03-28-07/metadata.json +14 -0
- package/src/test/fixtures/firebase-project/.temp/newsletter/run-2026-06-11T03-28-07/newsletter.html +486 -0
- package/src/test/fixtures/firebase-project/.temp/newsletter/run-2026-06-11T03-28-07/newsletter.md +41 -0
- package/src/test/fixtures/firebase-project/.temp/newsletter/run-2026-06-11T03-28-07/newsletter.mjml +97 -0
- package/{test/email/fixtures/clean.json → src/test/fixtures/firebase-project/.temp/newsletter/run-2026-06-11T03-28-07/structure.json} +16 -4
- package/src/test/fixtures/firebase-project/.temp/newsletter/run-2026-06-11T03-28-07/summary.md +1 -0
- package/src/test/fixtures/firebase-project/.temp/newsletter/run-2026-06-18T01-00-28/metadata.json +14 -0
- package/src/test/fixtures/firebase-project/.temp/newsletter/run-2026-06-18T01-00-28/newsletter.html +486 -0
- package/src/test/fixtures/firebase-project/.temp/newsletter/run-2026-06-18T01-00-28/newsletter.md +41 -0
- package/src/test/fixtures/firebase-project/.temp/newsletter/run-2026-06-18T01-00-28/newsletter.mjml +97 -0
- package/src/test/fixtures/firebase-project/.temp/newsletter/run-2026-06-18T01-00-28/structure.json +42 -0
- package/src/test/fixtures/firebase-project/.temp/newsletter/run-2026-06-18T01-00-28/summary.md +1 -0
- package/src/test/fixtures/firebase-project/.temp/newsletter/run-2026-06-18T01-01-34/metadata.json +14 -0
- package/src/test/fixtures/firebase-project/.temp/newsletter/run-2026-06-18T01-01-34/newsletter.html +486 -0
- package/src/test/fixtures/firebase-project/.temp/newsletter/run-2026-06-18T01-01-34/newsletter.md +41 -0
- package/src/test/fixtures/firebase-project/.temp/newsletter/run-2026-06-18T01-01-34/newsletter.mjml +97 -0
- package/src/test/fixtures/firebase-project/.temp/newsletter/run-2026-06-18T01-01-34/structure.json +42 -0
- package/src/test/fixtures/firebase-project/.temp/newsletter/run-2026-06-18T01-01-34/summary.md +1 -0
- package/src/test/fixtures/firebase-project/.temp/test-mode.json +6 -0
- package/src/test/fixtures/firebase-project/database-debug.log +12 -0
- package/src/test/fixtures/firebase-project/firestore-debug.log +136 -0
- package/src/test/fixtures/firebase-project/pubsub-debug.log +17 -0
- package/.codeclimate.yml +0 -32
- package/.nvmrc +0 -1
- package/CHANGELOG.md +0 -1440
- package/PROGRESS.md +0 -93
- package/TODO-2.md +0 -121
- package/TODO-CHARGEBLAST.md +0 -32
- package/TODO-WEBHOOK-KEY-LEGACY-REMOVAL.md +0 -15
- package/TODO-WEBHOOK-KEY-UPGRADE.md +0 -138
- package/TODO-email-auth.md +0 -14
- package/TODO.md +0 -187
- package/plans/mcp2.md +0 -247
- package/plans/suspended-subscription-dead-zone.md +0 -97
- package/scripts/test-helper-providers.js +0 -162
- package/scripts/update-disposable-domains.js +0 -44
- package/templates/_.env +0 -42
- package/templates/_.gitignore +0 -60
- package/templates/backend-manager-config.json +0 -249
- package/templates/database.rules.json +0 -83
- package/templates/firebase.json +0 -66
- package/templates/firestore.indexes.json +0 -4
- package/templates/firestore.rules +0 -112
- package/templates/public/404.html +0 -26
- package/templates/public/index.html +0 -24
- package/templates/remoteconfig.template.json +0 -1
- package/templates/storage-lifecycle-config-1-day.json +0 -9
- package/templates/storage-lifecycle-config-30-days.json +0 -9
- package/templates/storage.rules +0 -8
- package/test/_init/accounts-validation.js +0 -58
- package/test/_legacy/ai/index.js +0 -231
- package/test/_legacy/ai/test.jpg +0 -0
- package/test/_legacy/ai/test.pdf +0 -0
- package/test/_legacy/index.js +0 -31
- package/test/_legacy/payment-resolver/chargebee/orders/refunded.json +0 -0
- package/test/_legacy/payment-resolver/chargebee/orders/unpaid.json +0 -98
- package/test/_legacy/payment-resolver/chargebee/subscriptions/active.json +0 -578
- package/test/_legacy/payment-resolver/chargebee/subscriptions/trial-in-trial.json +0 -38
- package/test/_legacy/payment-resolver/chargebee/subscriptions/trial-skipped-to-active.json +0 -135
- package/test/_legacy/payment-resolver/chargebee/subscriptions/trial-to-active-to-cancelled.json +0 -42
- package/test/_legacy/payment-resolver/chargebee/subscriptions/trial-to-active.json +0 -44
- package/test/_legacy/payment-resolver/chargebee/subscriptions/trial-to-cancelled.json +0 -39
- package/test/_legacy/payment-resolver/chargebee/subscriptions/trial-to-refund.json +0 -143
- package/test/_legacy/payment-resolver/chargebee/subscriptions/trial-to-suspended.json +0 -48
- package/test/_legacy/payment-resolver/coinbase/orders/regular.json +0 -204
- package/test/_legacy/payment-resolver/coinbase/subscriptions/cancelled.json +0 -204
- package/test/_legacy/payment-resolver/coinbase/subscriptions/paid-2.json +0 -125
- package/test/_legacy/payment-resolver/index.js +0 -1558
- package/test/_legacy/payment-resolver/paypal/orders/refunded.json +0 -0
- package/test/_legacy/payment-resolver/paypal/orders/regular.json +0 -120
- package/test/_legacy/payment-resolver/paypal/subscriptions/active-refund-previous-stmnt.json +0 -323
- package/test/_legacy/payment-resolver/paypal/subscriptions/active.json +0 -192
- package/test/_legacy/payment-resolver/paypal/subscriptions/trial-in-trial.json +0 -125
- package/test/_legacy/payment-resolver/paypal/subscriptions/trial-payment-not-complete.json +0 -76
- package/test/_legacy/payment-resolver/paypal/subscriptions/trial-payment-overdue-2.json +0 -114
- package/test/_legacy/payment-resolver/paypal/subscriptions/trial-payment-overdue.json +0 -114
- package/test/_legacy/payment-resolver/paypal/subscriptions/trial-to-active-to-cancelled.json +0 -111
- package/test/_legacy/payment-resolver/paypal/subscriptions/trial-to-active.json +0 -132
- package/test/_legacy/payment-resolver/paypal/subscriptions/trial-to-cancelled.json +0 -107
- package/test/_legacy/payment-resolver/paypal/subscriptions/trial-to-expired.json +0 -91
- package/test/_legacy/payment-resolver/paypal/subscriptions/trial-to-refund.json +0 -147
- package/test/_legacy/payment-resolver/paypal/subscriptions/trial-to-suspended.json +0 -120
- package/test/_legacy/payment-resolver/stripe/orders/refunded.json +0 -0
- package/test/_legacy/payment-resolver/stripe/orders/regular.json +0 -91
- package/test/_legacy/payment-resolver/stripe/subscriptions/active.json +0 -421
- package/test/_legacy/payment-resolver/stripe/subscriptions/trial-in-trial.json +0 -349
- package/test/_legacy/payment-resolver/stripe/subscriptions/trial-to-active-failed-authorization.json +0 -430
- package/test/_legacy/payment-resolver/stripe/subscriptions/trial-to-active.json +0 -319
- package/test/_legacy/payment-resolver/stripe/subscriptions/trial-to-cancelled.json +0 -319
- package/test/_legacy/payment-resolver/stripe/subscriptions/trial-to-refund.json +0 -414
- package/test/_legacy/payment-resolver/stripe/subscriptions/trial-to-suspended.json +0 -319
- package/test/_legacy/payment-resolver/stripe/subscriptions/unsure.json +0 -339
- package/test/_legacy/test-new +0 -1178
- package/test/_legacy/test.md +0 -89
- package/test/_legacy/usage.js +0 -175
- package/test/_legacy/user.js +0 -31
- package/test/ai/tools-live.js +0 -170
- package/test/boot/emulator-boots.js +0 -37
- package/test/content/blog-generate.js +0 -160
- package/test/email/campaign-send.js +0 -45
- package/test/email/consent-lifecycle.js +0 -257
- package/test/email/feedback-and-plain-send.js +0 -52
- package/test/email/fixtures/editorial.json +0 -30
- package/test/email/fixtures/field-report.json +0 -53
- package/test/email/marketing-lifecycle.js +0 -132
- package/test/email/newsletter-generate.js +0 -819
- package/test/email/newsletter-templates.js +0 -491
- package/test/email/templates.js +0 -207
- package/test/email/transactional-send.js +0 -34
- package/test/email/transactional.js +0 -560
- package/test/email/validation.js +0 -710
- package/test/events/auth-delete-race.js +0 -201
- package/test/events/payments/journey-payments-cancel-endpoint.js +0 -100
- package/test/events/payments/journey-payments-cancel.js +0 -182
- package/test/events/payments/journey-payments-discount.js +0 -89
- package/test/events/payments/journey-payments-failure.js +0 -146
- package/test/events/payments/journey-payments-legacy-product.js +0 -149
- package/test/events/payments/journey-payments-one-time-failure.js +0 -111
- package/test/events/payments/journey-payments-one-time.js +0 -136
- package/test/events/payments/journey-payments-plan-change.js +0 -144
- package/test/events/payments/journey-payments-refund-webhook.js +0 -180
- package/test/events/payments/journey-payments-suspend.js +0 -181
- package/test/events/payments/journey-payments-trial-cancel.js +0 -130
- package/test/events/payments/journey-payments-trial.js +0 -171
- package/test/events/payments/journey-payments-uid-resolution.js +0 -132
- package/test/events/payments/journey-payments-upgrade.js +0 -135
- package/test/fixtures/chargebee/invoice-one-time.json +0 -27
- package/test/fixtures/chargebee/subscription-active.json +0 -44
- package/test/fixtures/chargebee/subscription-cancelled.json +0 -42
- package/test/fixtures/chargebee/subscription-in-trial.json +0 -41
- package/test/fixtures/chargebee/subscription-legacy-plan.json +0 -41
- package/test/fixtures/chargebee/subscription-non-renewing.json +0 -41
- package/test/fixtures/chargebee/subscription-paused.json +0 -42
- package/test/fixtures/chargebee/webhook-payment-failed.json +0 -51
- package/test/fixtures/chargebee/webhook-subscription-created.json +0 -47
- package/test/fixtures/paypal/order-approved.json +0 -62
- package/test/fixtures/paypal/order-completed.json +0 -110
- package/test/fixtures/paypal/subscription-active.json +0 -76
- package/test/fixtures/paypal/subscription-cancelled.json +0 -50
- package/test/fixtures/paypal/subscription-suspended.json +0 -65
- package/test/fixtures/stripe/checkout-session-completed.json +0 -130
- package/test/fixtures/stripe/invoice-payment-failed.json +0 -148
- package/test/fixtures/stripe/invoice-subscription-payment-failed.json +0 -28
- package/test/fixtures/stripe/subscription-active.json +0 -161
- package/test/fixtures/stripe/subscription-canceled.json +0 -161
- package/test/fixtures/stripe/subscription-trialing.json +0 -161
- package/test/functions/admin/database-read.js +0 -134
- package/test/functions/admin/database-write.js +0 -159
- package/test/functions/admin/edit-post.js +0 -366
- package/test/functions/admin/firestore-query.js +0 -207
- package/test/functions/admin/firestore-read.js +0 -129
- package/test/functions/admin/firestore-write.js +0 -105
- package/test/functions/admin/get-stats.js +0 -115
- package/test/functions/admin/send-email.js +0 -119
- package/test/functions/admin/send-notification.js +0 -208
- package/test/functions/admin/write-repo-content.js +0 -223
- package/test/functions/general/add-marketing-contact.js +0 -335
- package/test/functions/general/fetch-post.js +0 -54
- package/test/functions/general/generate-uuid.js +0 -114
- package/test/functions/test/authenticate.js +0 -64
- package/test/functions/user/create-custom-token.js +0 -73
- package/test/functions/user/delete.js +0 -135
- package/test/functions/user/get-active-sessions.js +0 -111
- package/test/functions/user/get-subscription-info.js +0 -99
- package/test/functions/user/regenerate-api-keys.js +0 -156
- package/test/functions/user/resolve.js +0 -52
- package/test/functions/user/sign-out-all-sessions.js +0 -94
- package/test/functions/user/sign-up.js +0 -252
- package/test/functions/user/submit-feedback.js +0 -104
- package/test/functions/user/validate-settings.js +0 -82
- package/test/helpers/ai-request-payload.js +0 -300
- package/test/helpers/ai-test-provider.js +0 -202
- package/test/helpers/ai-tools-format.js +0 -383
- package/test/helpers/content/blog-auto-publisher.js +0 -484
- package/test/helpers/content/feed-parser.js +0 -528
- package/test/helpers/content/ghostii-blocks.js +0 -134
- package/test/helpers/content/ghostii-feed-integration.js +0 -404
- package/test/helpers/content/ghostii-write-article.js +0 -243
- package/test/helpers/environment.js +0 -230
- package/test/helpers/infer-contact.js +0 -113
- package/test/helpers/merge-line-files.js +0 -271
- package/test/helpers/payment/chargebee/parse-webhook.js +0 -413
- package/test/helpers/payment/chargebee/to-unified-one-time.js +0 -147
- package/test/helpers/payment/chargebee/to-unified-subscription.js +0 -648
- package/test/helpers/payment/paypal/parse-webhook.js +0 -539
- package/test/helpers/payment/paypal/to-unified-one-time.js +0 -382
- package/test/helpers/payment/paypal/to-unified-subscription.js +0 -820
- package/test/helpers/payment/stripe/parse-webhook.js +0 -447
- package/test/helpers/payment/stripe/to-unified-one-time.js +0 -306
- package/test/helpers/payment/stripe/to-unified-subscription.js +0 -708
- package/test/helpers/sanitize.js +0 -222
- package/test/helpers/slugify.js +0 -394
- package/test/helpers/storage.js +0 -194
- package/test/helpers/user.js +0 -755
- package/test/helpers/webhook-forward.js +0 -418
- package/test/mcp/discovery.js +0 -53
- package/test/mcp/oauth.js +0 -161
- package/test/mcp/protocol.js +0 -268
- package/test/mcp/roles.js +0 -188
- package/test/mcp/utils.js +0 -245
- package/test/routes/admin/create-post.js +0 -364
- package/test/routes/admin/database.js +0 -131
- package/test/routes/admin/deduplicate-image-alts.js +0 -190
- package/test/routes/admin/email.js +0 -114
- package/test/routes/admin/firestore-query.js +0 -204
- package/test/routes/admin/firestore.js +0 -127
- package/test/routes/admin/infer-contact.js +0 -218
- package/test/routes/admin/notification.js +0 -198
- package/test/routes/admin/post-resize-image.js +0 -184
- package/test/routes/admin/post.js +0 -368
- package/test/routes/admin/repo-content.js +0 -223
- package/test/routes/admin/stats.js +0 -112
- package/test/routes/content/post.js +0 -54
- package/test/routes/general/uuid.js +0 -115
- package/test/routes/marketing/campaign.js +0 -125
- package/test/routes/marketing/contact.js +0 -370
- package/test/routes/marketing/email-preferences.js +0 -292
- package/test/routes/marketing/push-send.js +0 -32
- package/test/routes/marketing/webhook-forward.js +0 -61
- package/test/routes/marketing/webhook.js +0 -639
- package/test/routes/payments/cancel.js +0 -163
- package/test/routes/payments/discount.js +0 -80
- package/test/routes/payments/dispute-alert.js +0 -320
- package/test/routes/payments/intent.js +0 -336
- package/test/routes/payments/portal.js +0 -92
- package/test/routes/payments/refund.js +0 -179
- package/test/routes/payments/trial-eligibility.js +0 -71
- package/test/routes/payments/webhook.js +0 -107
- package/test/routes/test/authenticate.js +0 -59
- package/test/routes/test/schema.js +0 -554
- package/test/routes/test/usage.js +0 -342
- package/test/routes/user/api-keys.js +0 -156
- package/test/routes/user/delete.js +0 -136
- package/test/routes/user/feedback.js +0 -104
- package/test/routes/user/sessions.js +0 -199
- package/test/routes/user/settings-validate.js +0 -82
- package/test/routes/user/signup.js +0 -542
- package/test/routes/user/subscription.js +0 -99
- package/test/routes/user/token.js +0 -73
- package/test/routes/user/user.js +0 -157
- package/test/rules/notifications.js +0 -410
- package/test/rules/payments-carts.js +0 -371
- package/test/rules/user.js +0 -396
|
@@ -1,819 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Newsletter generation iteration test.
|
|
3
|
-
*
|
|
4
|
-
* Two modes:
|
|
5
|
-
*
|
|
6
|
-
* 1. FIXTURE MODE (default — runs in every test invocation, no env required)
|
|
7
|
-
* Loads a hand-crafted structure JSON from test/email/fixtures/<template>.json
|
|
8
|
-
* and renders it through the active template. No AI, no source fetching,
|
|
9
|
-
* no images, ~25-50ms render time, $0 cost. This is what runs in CI and
|
|
10
|
-
* what you iterate against for layout/CSS changes.
|
|
11
|
-
*
|
|
12
|
-
* Fixture-name resolution priority:
|
|
13
|
-
* 1. NEWSLETTER_FIXTURE=<name> (explicit override)
|
|
14
|
-
* 2. NEWSLETTER_TEMPLATE=<name> (use the fixture for the override template)
|
|
15
|
-
* 3. config.marketing.newsletter.content.template (use the active brand's template)
|
|
16
|
-
* 4. 'clean' (universal fallback)
|
|
17
|
-
*
|
|
18
|
-
* Add a new template? Drop a matching JSON in test/email/fixtures/<name>.json.
|
|
19
|
-
* The fixture's content shape MUST match the template's `schema` export.
|
|
20
|
-
*
|
|
21
|
-
* 2. AI PIPELINE MODE (set TEST_EXTENDED_MODE=1)
|
|
22
|
-
* Pulls real sources from the parent BEM server, runs them through the
|
|
23
|
-
* structure → SVG → MJML pipeline, and writes a preview HTML. Same code
|
|
24
|
-
* path the daily pre-generation cron uses. Costs money (AI tokens). Use
|
|
25
|
-
* this when you want to evaluate prompt quality against real sources.
|
|
26
|
-
*
|
|
27
|
-
* Output (both modes): <projectRoot>/.temp/newsletter/run-<timestamp>/ (one level above functions/)
|
|
28
|
-
*
|
|
29
|
-
* Run from somiibo-backend/functions:
|
|
30
|
-
* npx mgr test project:marketing/newsletter-generate.js # fixture (fast, free)
|
|
31
|
-
* NEWSLETTER_FIXTURE=editorial npx mgr test project:marketing/newsletter-generate.js
|
|
32
|
-
* TEST_EXTENDED_MODE=1 npx mgr test project:marketing/newsletter-generate.js # AI pipeline
|
|
33
|
-
*
|
|
34
|
-
* --- Env vars (most apply to AI mode only) ---
|
|
35
|
-
* NEWSLETTER_FIXTURE=<name> Load and render a specific fixture (fixture mode).
|
|
36
|
-
* NEWSLETTER_TEMPLATE=<name> Override the layout template for this run.
|
|
37
|
-
* NEWSLETTER_THEME_ONLY=1 Reuse the most recent AI run's structure.json + PNGs
|
|
38
|
-
* and re-render. Different from FIXTURE: this loads
|
|
39
|
-
* prior AI output, FIXTURE loads hand-crafted JSON.
|
|
40
|
-
* NEWSLETTER_REUSE_RUN=<dir> Pair with THEME_ONLY: reuse a specific run dir.
|
|
41
|
-
* NEWSLETTER_OPEN=1 Auto-open the rendered preview in the default browser (macOS only).
|
|
42
|
-
*
|
|
43
|
-
* --- AI-mode-only env vars (require TEST_EXTENDED_MODE=1) ---
|
|
44
|
-
* NEWSLETTER_PEEK=1 Fetch + list ready sources, do not claim, exit.
|
|
45
|
-
* NEWSLETTER_SOURCE_ID=<id> Generate from one specific source WITHOUT claiming it.
|
|
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.
|
|
50
|
-
* NEWSLETTER_RELEASE=1 Reset locally-tracked claimed sources back to 'ready'.
|
|
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.newsletter.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.
|
|
57
|
-
* NEWSLETTER_PROVIDER_STRUCTURE=X Override structure provider (openai|anthropic).
|
|
58
|
-
* NEWSLETTER_PROVIDER_SVG=X Override SVG provider (openai|anthropic).
|
|
59
|
-
* NEWSLETTER_CAMPAIGN_ID=<id> Override the auto-generated campaign ID (folder name in newsletter-assets).
|
|
60
|
-
*
|
|
61
|
-
* In EXTENDED mode, the test ALWAYS uploads PNGs + newsletter.html to GitHub
|
|
62
|
-
* and creates a Beehiiv draft — same side effects as the production cron.
|
|
63
|
-
* No opt-out, no per-side-effect flag. EXTENDED = production-equivalent run.
|
|
64
|
-
*
|
|
65
|
-
* --- THEME_ONLY-mode-only env vars ---
|
|
66
|
-
* NEWSLETTER_BEEHIIV_UPLOAD=1 After re-rendering, upload as a new Beehiiv draft (rare).
|
|
67
|
-
*
|
|
68
|
-
* AI mode requires:
|
|
69
|
-
* BACKEND_MANAGER_KEY — authenticates with parent as admin
|
|
70
|
-
* OPENAI_API_KEY — structure provider (or BACKEND_MANAGER_OPENAI_API_KEY)
|
|
71
|
-
* ANTHROPIC_API_KEY — SVG provider (or BACKEND_MANAGER_ANTHROPIC_API_KEY)
|
|
72
|
-
* PARENT_API_URL — or set `parent` in backend-manager-config.json
|
|
73
|
-
*
|
|
74
|
-
* Fixture mode requires: nothing.
|
|
75
|
-
*/
|
|
76
|
-
const path = require('path');
|
|
77
|
-
const { execSync } = require('child_process');
|
|
78
|
-
const fetch = require('wonderful-fetch');
|
|
79
|
-
const jetpack = require('fs-jetpack');
|
|
80
|
-
|
|
81
|
-
module.exports = {
|
|
82
|
-
description: 'Generate a newsletter preview (fixture by default, full AI pipeline with TEST_EXTENDED_MODE)',
|
|
83
|
-
auth: 'none',
|
|
84
|
-
timeout: 300000, // 5 min — AI structure + image generation + GitHub upload + Beehiiv draft
|
|
85
|
-
// The test ALWAYS runs. By default it renders a hand-crafted fixture for the
|
|
86
|
-
// active template (fast, free, deterministic — catches layout regressions in
|
|
87
|
-
// CI). Set TEST_EXTENDED_MODE=1 to switch to the full AI pipeline that fetches
|
|
88
|
-
// real sources, calls the structure + SVG providers, and writes a preview.
|
|
89
|
-
// Other modes (FIXTURE, THEME_ONLY, RELEASE, PEEK) are also opt-in via env.
|
|
90
|
-
async run({ assert, config, Manager, assistant, skip }) {
|
|
91
|
-
const env = process.env;
|
|
92
|
-
|
|
93
|
-
// --- Apply env overrides into newsletterConfig ---
|
|
94
|
-
// Newsletter config now lives under marketing.newsletter.content.
|
|
95
|
-
const newsletterConfig = JSON.parse(JSON.stringify(config.marketing?.newsletter?.content || {}));
|
|
96
|
-
|
|
97
|
-
if (env.NEWSLETTER_PROVIDER_STRUCTURE) {
|
|
98
|
-
newsletterConfig.provider = { ...(newsletterConfig.provider || {}), structure: env.NEWSLETTER_PROVIDER_STRUCTURE };
|
|
99
|
-
}
|
|
100
|
-
if (env.NEWSLETTER_PROVIDER_SVG) {
|
|
101
|
-
newsletterConfig.provider = { ...(newsletterConfig.provider || {}), svg: env.NEWSLETTER_PROVIDER_SVG };
|
|
102
|
-
}
|
|
103
|
-
if (env.NEWSLETTER_TEMPLATE) {
|
|
104
|
-
newsletterConfig.template = env.NEWSLETTER_TEMPLATE;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
// --- Build the output dir ---
|
|
108
|
-
const stamp = new Date().toISOString().replace(/[:.]/g, '-').replace(/-(\d{3})Z$/, '');
|
|
109
|
-
// .temp lives at the CONSUMER PROJECT ROOT (e.g. somiibo-backend/.temp), not
|
|
110
|
-
// inside functions/. Matches the convention used by UJM, BXM, electron-manager,
|
|
111
|
-
// etc. — every project's transient cache directory sits at the repo root.
|
|
112
|
-
const outRoot = path.join(process.cwd(), '..', '.temp', 'newsletter');
|
|
113
|
-
const runDir = path.join(outRoot, `run-${stamp}`);
|
|
114
|
-
const claimedFile = path.join(outRoot, '.claimed.json');
|
|
115
|
-
|
|
116
|
-
jetpack.dir(runDir);
|
|
117
|
-
|
|
118
|
-
// --- Release mode (early return) ---
|
|
119
|
-
if (env.NEWSLETTER_RELEASE) {
|
|
120
|
-
const released = await releaseAll(claimedFile);
|
|
121
|
-
console.log(`Released ${released} previously claimed source(s) back to ready`);
|
|
122
|
-
return;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
// --- Fixture mode ---
|
|
126
|
-
// Default behavior of this test. Loads a hand-crafted structure JSON from
|
|
127
|
-
// test/email/fixtures/{name}.json and renders it directly. No AI, no
|
|
128
|
-
// source fetching, no images. Used as the deterministic "predictable
|
|
129
|
-
// preview" loop AND as the default test-suite render.
|
|
130
|
-
//
|
|
131
|
-
// Fixture-name resolution priority:
|
|
132
|
-
// 1. NEWSLETTER_FIXTURE=<name> (explicit override)
|
|
133
|
-
// 2. NEWSLETTER_TEMPLATE=<name> (use the fixture for the override template)
|
|
134
|
-
// 3. newsletterConfig.template (use the fixture for the active brand's template)
|
|
135
|
-
// 4. 'clean' (universal fallback)
|
|
136
|
-
//
|
|
137
|
-
// This means a fresh `npx mgr test` against any consumer project picks the
|
|
138
|
-
// brand's configured template, renders the matching fixture, and produces
|
|
139
|
-
// a deterministic preview HTML — no AI, no money spent.
|
|
140
|
-
//
|
|
141
|
-
// The full AI pipeline only runs when TEST_EXTENDED_MODE=1 is set (below).
|
|
142
|
-
// Explicit NEWSLETTER_FIXTURE wins even in EXTENDED mode (you can ask for
|
|
143
|
-
// a fixture render mid-AI-iteration without re-cycling envs).
|
|
144
|
-
// NEWSLETTER_THEME_ONLY takes precedence below — it's a no-AI mode that
|
|
145
|
-
// reuses prior AI output, distinct from fixture mode.
|
|
146
|
-
if (env.NEWSLETTER_THEME_ONLY) {
|
|
147
|
-
// Fall through to the theme-only block below.
|
|
148
|
-
} else if (!env.TEST_EXTENDED_MODE || env.NEWSLETTER_FIXTURE) {
|
|
149
|
-
const requestedFixture = (env.NEWSLETTER_FIXTURE || env.NEWSLETTER_TEMPLATE || newsletterConfig.template || 'clean')
|
|
150
|
-
.replace(/^fixture:/, '');
|
|
151
|
-
const fixturePath = path.join(__dirname, 'fixtures', `${requestedFixture}.json`);
|
|
152
|
-
|
|
153
|
-
assert.ok(jetpack.exists(fixturePath),
|
|
154
|
-
`Fixture not found: ${fixturePath}. Available fixtures: ${jetpack.list(path.join(__dirname, 'fixtures')).filter((f) => f.endsWith('.json')).join(', ')}. Every registered template should ship a matching fixture in test/email/fixtures/.`);
|
|
155
|
-
|
|
156
|
-
const structure = jetpack.read(fixturePath, 'json');
|
|
157
|
-
|
|
158
|
-
assert.ok(structure, `Fixture ${requestedFixture}.json failed to parse as JSON`);
|
|
159
|
-
|
|
160
|
-
// Pin the template to the fixture name — fixtures are authored for a
|
|
161
|
-
// specific template's content shape, so this prevents the obvious bug
|
|
162
|
-
// of "render the field-report fixture through the clean template".
|
|
163
|
-
// Explicit NEWSLETTER_TEMPLATE override is still honored above.
|
|
164
|
-
if (!env.NEWSLETTER_TEMPLATE) {
|
|
165
|
-
newsletterConfig.template = requestedFixture;
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
const { renderNewsletter } = require('../../src/manager/libraries/email/generators/lib/mjml-template.js');
|
|
169
|
-
const { renderMarkdown } = require('../../src/manager/libraries/email/generators/lib/markdown-renderer.js');
|
|
170
|
-
|
|
171
|
-
const renderStart = Date.now();
|
|
172
|
-
const { html, mjml, template: templateName } = await renderNewsletter({
|
|
173
|
-
brand: config.brand,
|
|
174
|
-
newsletterConfig,
|
|
175
|
-
structure,
|
|
176
|
-
imagePaths: [],
|
|
177
|
-
campaign: `fixture-${requestedFixture}`,
|
|
178
|
-
});
|
|
179
|
-
// Force the template metadata onto structure so renderMarkdown picks
|
|
180
|
-
// the right body strategy (fixtures don't carry _meta on their own).
|
|
181
|
-
Object.defineProperty(structure, '_meta', {
|
|
182
|
-
enumerable: false,
|
|
183
|
-
configurable: true,
|
|
184
|
-
value: { template: templateName },
|
|
185
|
-
});
|
|
186
|
-
const markdown = renderMarkdown({
|
|
187
|
-
structure,
|
|
188
|
-
brand: config.brand,
|
|
189
|
-
imagePaths: [],
|
|
190
|
-
});
|
|
191
|
-
const renderMs = Date.now() - renderStart;
|
|
192
|
-
|
|
193
|
-
const previewPath = path.join(runDir, 'newsletter.html');
|
|
194
|
-
jetpack.write(previewPath, html);
|
|
195
|
-
jetpack.write(path.join(runDir, 'newsletter.md'), markdown);
|
|
196
|
-
if (structure.summary) {
|
|
197
|
-
jetpack.write(path.join(runDir, 'summary.md'), structure.summary + '\n');
|
|
198
|
-
}
|
|
199
|
-
jetpack.write(path.join(runDir, 'newsletter.mjml'), mjml || '');
|
|
200
|
-
jetpack.write(path.join(runDir, 'structure.json'), JSON.stringify(structure, null, 2));
|
|
201
|
-
jetpack.write(path.join(runDir, 'metadata.json'), JSON.stringify({
|
|
202
|
-
mode: 'fixture',
|
|
203
|
-
fixture: requestedFixture,
|
|
204
|
-
template: templateName,
|
|
205
|
-
renderMs,
|
|
206
|
-
tags: structure.tags || [],
|
|
207
|
-
timestamp: new Date().toISOString(),
|
|
208
|
-
}, null, 2));
|
|
209
|
-
|
|
210
|
-
console.log(`\n[fixture=${requestedFixture}] Rendered in ${renderMs}ms using template: ${templateName}`);
|
|
211
|
-
console.log(`[fixture=${requestedFixture}] Preview: ${previewPath}`);
|
|
212
|
-
console.log(`[fixture=${requestedFixture}] Markdown: ${path.join(runDir, 'newsletter.md')}`);
|
|
213
|
-
console.log(`[fixture=${requestedFixture}] (Set TEST_EXTENDED_MODE=1 to switch to the full AI pipeline.)`);
|
|
214
|
-
|
|
215
|
-
if (env.NEWSLETTER_OPEN === '1' && process.platform === 'darwin') {
|
|
216
|
-
try { execSync(`open "${previewPath}"`); } catch (e) { /* no-op */ }
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
assert.ok(html.includes('<html'), 'Rendered HTML');
|
|
220
|
-
assert.ok(markdown.includes('# '), 'Rendered markdown has a heading');
|
|
221
|
-
return;
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
// --- Theme-only mode (early return) ---
|
|
225
|
-
// Reuse a previous run's structure.json + PNGs to re-render MJML/HTML only.
|
|
226
|
-
// Sub-second iteration on layout / theme tokens with no AI cost.
|
|
227
|
-
if (env.NEWSLETTER_THEME_ONLY) {
|
|
228
|
-
const sourceRun = resolveReuseRun({
|
|
229
|
-
outRoot,
|
|
230
|
-
explicit: env.NEWSLETTER_REUSE_RUN,
|
|
231
|
-
});
|
|
232
|
-
|
|
233
|
-
assert.ok(sourceRun, 'Found a previous run to reuse (set NEWSLETTER_REUSE_RUN=run-<stamp> if needed)');
|
|
234
|
-
|
|
235
|
-
console.log(`[theme-only] reusing structure + images from: ${path.basename(sourceRun)}`);
|
|
236
|
-
|
|
237
|
-
const structure = jetpack.read(path.join(sourceRun, 'structure.json'), 'json');
|
|
238
|
-
|
|
239
|
-
assert.ok(structure?.sections?.length, 'Previous run has a valid structure.json');
|
|
240
|
-
|
|
241
|
-
// Copy section PNGs from previous run into this run's dir so paths resolve
|
|
242
|
-
const imagePaths = [];
|
|
243
|
-
|
|
244
|
-
for (let i = 0; i < structure.sections.length; i++) {
|
|
245
|
-
const filename = `section-${i + 1}.png`;
|
|
246
|
-
const srcPath = path.join(sourceRun, filename);
|
|
247
|
-
|
|
248
|
-
if (jetpack.exists(srcPath)) {
|
|
249
|
-
jetpack.copy(srcPath, path.join(runDir, filename));
|
|
250
|
-
imagePaths.push(`./${filename}`);
|
|
251
|
-
} else {
|
|
252
|
-
imagePaths.push(null);
|
|
253
|
-
}
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
const { renderNewsletter } = require('../../src/manager/libraries/email/generators/lib/mjml-template.js');
|
|
257
|
-
const { renderMarkdown } = require('../../src/manager/libraries/email/generators/lib/markdown-renderer.js');
|
|
258
|
-
|
|
259
|
-
const renderStart = Date.now();
|
|
260
|
-
const { html, mjml, template: templateName } = await renderNewsletter({
|
|
261
|
-
brand: config.brand,
|
|
262
|
-
newsletterConfig,
|
|
263
|
-
structure,
|
|
264
|
-
imagePaths,
|
|
265
|
-
campaign: `theme-only-${stamp}`,
|
|
266
|
-
});
|
|
267
|
-
// Force the template metadata onto structure so renderMarkdown picks
|
|
268
|
-
// the right body strategy (reused structures may have lost _meta).
|
|
269
|
-
Object.defineProperty(structure, '_meta', {
|
|
270
|
-
enumerable: false,
|
|
271
|
-
configurable: true,
|
|
272
|
-
value: { template: templateName },
|
|
273
|
-
});
|
|
274
|
-
const markdown = renderMarkdown({
|
|
275
|
-
structure,
|
|
276
|
-
brand: config.brand,
|
|
277
|
-
imagePaths,
|
|
278
|
-
});
|
|
279
|
-
const renderMs = Date.now() - renderStart;
|
|
280
|
-
|
|
281
|
-
const previewPath = path.join(runDir, 'newsletter.html');
|
|
282
|
-
jetpack.write(previewPath, html);
|
|
283
|
-
jetpack.write(path.join(runDir, 'newsletter.md'), markdown);
|
|
284
|
-
if (structure.summary) {
|
|
285
|
-
jetpack.write(path.join(runDir, 'summary.md'), structure.summary + '\n');
|
|
286
|
-
}
|
|
287
|
-
jetpack.write(path.join(runDir, 'newsletter.mjml'), mjml || '');
|
|
288
|
-
jetpack.write(path.join(runDir, 'structure.json'), JSON.stringify(structure, null, 2));
|
|
289
|
-
jetpack.write(path.join(runDir, 'metadata.json'), JSON.stringify({
|
|
290
|
-
mode: 'theme-only',
|
|
291
|
-
reusedFrom: path.basename(sourceRun),
|
|
292
|
-
template: templateName,
|
|
293
|
-
renderMs,
|
|
294
|
-
tags: structure.tags || [],
|
|
295
|
-
timestamp: new Date().toISOString(),
|
|
296
|
-
}, null, 2));
|
|
297
|
-
|
|
298
|
-
console.log(`\n[theme-only] Rendered in ${renderMs}ms using template: ${templateName}`);
|
|
299
|
-
console.log(`[theme-only] Preview: ${previewPath}`);
|
|
300
|
-
|
|
301
|
-
// Optional: upload to Beehiiv as a draft
|
|
302
|
-
if (env.NEWSLETTER_BEEHIIV_UPLOAD) {
|
|
303
|
-
await uploadDraftToBeehiiv({
|
|
304
|
-
html,
|
|
305
|
-
structure,
|
|
306
|
-
config,
|
|
307
|
-
assistant: console,
|
|
308
|
-
runDir,
|
|
309
|
-
});
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
if (env.NEWSLETTER_OPEN === '1' && process.platform === 'darwin') {
|
|
313
|
-
try { execSync(`open "${previewPath}"`); } catch (e) { /* no-op */ }
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
assert.ok(html.includes('<html'), 'Rendered HTML');
|
|
317
|
-
return;
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
// --- AI pipeline path (TEST_EXTENDED_MODE) ---
|
|
321
|
-
// Everything below this point talks to the real parent server and the AI
|
|
322
|
-
// providers. The parent URL is required for any of it.
|
|
323
|
-
// Use Manager.getParentApiUrl() — same helper the production newsletter
|
|
324
|
-
// generator uses. config.parent stores the parent's brand URL WITHOUT the
|
|
325
|
-
// `api.` subdomain (e.g. 'https://itwcreativeworks.com'); the helper
|
|
326
|
-
// inserts `api.` at call time. PARENT_API_URL env override is honored
|
|
327
|
-
// verbatim for one-off testing against a different parent.
|
|
328
|
-
const parentUrl = env.PARENT_API_URL || Manager.getParentApiUrl();
|
|
329
|
-
assert.ok(parentUrl, 'PARENT_API_URL (env) or parent (config) must be set for the AI pipeline. Set TEST_EXTENDED_MODE=1 to run it, or omit TEST_EXTENDED_MODE for the fast fixture preview.');
|
|
330
|
-
|
|
331
|
-
// --- Peek mode (early return) ---
|
|
332
|
-
if (env.NEWSLETTER_PEEK) {
|
|
333
|
-
const sources = await peekSources({
|
|
334
|
-
parentUrl,
|
|
335
|
-
categories: newsletterConfig.categories || [],
|
|
336
|
-
limit: parseInt(env.NEWSLETTER_LIMIT, 10) || 10,
|
|
337
|
-
key: env.BACKEND_MANAGER_KEY,
|
|
338
|
-
});
|
|
339
|
-
|
|
340
|
-
console.log(`\nPeek mode — ${sources.length} ready source(s):\n`);
|
|
341
|
-
sources.forEach((s, i) => {
|
|
342
|
-
const raw = s.source || {};
|
|
343
|
-
const cats = (s.categories || []).join(', ') || s.category || '(none)';
|
|
344
|
-
console.log(`[${i + 1}] ${s.id}`);
|
|
345
|
-
console.log(` Categories: ${cats}`);
|
|
346
|
-
console.log(` From: ${raw.from || s.from || '(unknown)'}`);
|
|
347
|
-
console.log(` Subject: ${raw.subject || s.subject || '(none)'}`);
|
|
348
|
-
console.log(` Headline: ${s.ai?.headline || '(none — not yet AI-processed)'}`);
|
|
349
|
-
console.log('');
|
|
350
|
-
});
|
|
351
|
-
|
|
352
|
-
assert.ok(true, `Peeked ${sources.length} sources`);
|
|
353
|
-
return;
|
|
354
|
-
}
|
|
355
|
-
|
|
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;
|
|
363
|
-
const sources = await fetchSourcesForRun({
|
|
364
|
-
parentUrl,
|
|
365
|
-
newsletterConfig,
|
|
366
|
-
brandId: config.brand?.id,
|
|
367
|
-
sourceId: env.NEWSLETTER_SOURCE_ID,
|
|
368
|
-
key: env.BACKEND_MANAGER_KEY,
|
|
369
|
-
claim,
|
|
370
|
-
});
|
|
371
|
-
|
|
372
|
-
// Environmental precondition: the parent server must have ready sources in
|
|
373
|
-
// at least one configured category. Skip cleanly when the pool is empty
|
|
374
|
-
// (transient state — no point hard-failing CI on an external queue).
|
|
375
|
-
if (sources.length === 0) {
|
|
376
|
-
return skip('No ready newsletter sources available on parent server (environmental)');
|
|
377
|
-
}
|
|
378
|
-
|
|
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
|
-
}
|
|
383
|
-
|
|
384
|
-
// Force `newsletter.enabled: true` and inject the per-run newsletter config
|
|
385
|
-
// overrides onto Manager.config. The iteration test IS the explicit trigger
|
|
386
|
-
// — we're not checking whether newsletter is configured for prod use, we're
|
|
387
|
-
// driving the generator directly. Mutating Manager.config is fine here
|
|
388
|
-
// because this is a `type: 'standalone'` test (one test per process — no
|
|
389
|
-
// cross-test config leakage).
|
|
390
|
-
Manager.config.marketing = {
|
|
391
|
-
...(Manager.config.marketing || {}),
|
|
392
|
-
newsletter: {
|
|
393
|
-
...(Manager.config.marketing?.newsletter || {}),
|
|
394
|
-
enabled: true,
|
|
395
|
-
content: newsletterConfig,
|
|
396
|
-
},
|
|
397
|
-
};
|
|
398
|
-
|
|
399
|
-
// --- Run the production generator with the local-persist image hook ---
|
|
400
|
-
const generator = require('../../src/manager/libraries/email/generators/newsletter.js');
|
|
401
|
-
|
|
402
|
-
// EXTENDED mode mirrors the production cron's newsletter side effects:
|
|
403
|
-
// GH upload always happens (PNGs + newsletter.html), Beehiiv draft upload
|
|
404
|
-
// always happens (governed inside newsletter.js by newsletter.enabled, which
|
|
405
|
-
// we force true above). If you don't want the side effects, run fixture
|
|
406
|
-
// mode instead.
|
|
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
|
-
//
|
|
414
|
-
// persistImage is a side-effect callback that writes PNG+SVG to runDir for
|
|
415
|
-
// local preview / debug. Its return value is ignored when imageHost: 'github'
|
|
416
|
-
// because the generator uses the uploaded CDN URLs in the rendered HTML.
|
|
417
|
-
const persistImage = async (image, idx) => {
|
|
418
|
-
const filename = `section-${idx + 1}.png`;
|
|
419
|
-
|
|
420
|
-
jetpack.write(path.join(runDir, filename), image.png);
|
|
421
|
-
if (image.svg) {
|
|
422
|
-
jetpack.write(path.join(runDir, `section-${idx + 1}.svg`), image.svg);
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
return `./${filename}`;
|
|
426
|
-
};
|
|
427
|
-
|
|
428
|
-
// The campaignId becomes the GitHub folder name. Either pinned via env
|
|
429
|
-
// (for repeatable iteration on the same folder) or auto-generated by
|
|
430
|
-
// newsletter.generate() in Firestore auto-ID shape.
|
|
431
|
-
const campaignId = env.NEWSLETTER_CAMPAIGN_ID || undefined;
|
|
432
|
-
|
|
433
|
-
console.log(`[extended] uploading to itw-creative-works/newsletter-assets/${config.brand?.id}/${campaignId || '<auto-id>'}/ + Beehiiv draft`);
|
|
434
|
-
|
|
435
|
-
const result = await generator.generate(
|
|
436
|
-
Manager,
|
|
437
|
-
assistant,
|
|
438
|
-
{ name: `Somiibo Newsletter — Iteration ${stamp}` },
|
|
439
|
-
{
|
|
440
|
-
sources,
|
|
441
|
-
skipClaim: true, // We manage the claim/release lifecycle ourselves
|
|
442
|
-
skipImages: !!env.NEWSLETTER_NO_IMAGES,
|
|
443
|
-
// The article is GENERATED whenever the brand's config.article.enabled is on
|
|
444
|
-
// (exercises the Ghostii write + URL/CTA path), but only PUBLISHED to the
|
|
445
|
-
// website repo when you opt in with NEWSLETTER_CREATE_ARTICLE=1. Default
|
|
446
|
-
// test run generates but does not commit a real post.
|
|
447
|
-
publishArticle: !!env.NEWSLETTER_CREATE_ARTICLE,
|
|
448
|
-
// Local disk persistence runs unconditionally (for preview/debug)
|
|
449
|
-
persistImage,
|
|
450
|
-
// EXTENDED always uploads to GitHub — mirrors production cron exactly
|
|
451
|
-
imageHost: 'github',
|
|
452
|
-
campaignId,
|
|
453
|
-
}
|
|
454
|
-
);
|
|
455
|
-
|
|
456
|
-
assert.ok(result, 'Generator returned a result');
|
|
457
|
-
assert.ok(result.contentHtml, 'Generator returned contentHtml');
|
|
458
|
-
assert.ok(result.contentMarkdown, 'Generator returned contentMarkdown');
|
|
459
|
-
assert.ok(result.structure?.sections?.length >= 2, 'Has at least 2 sections');
|
|
460
|
-
|
|
461
|
-
// --- Write outputs ---
|
|
462
|
-
const previewPath = path.join(runDir, 'newsletter.html');
|
|
463
|
-
jetpack.write(previewPath, result.contentHtml);
|
|
464
|
-
jetpack.write(path.join(runDir, 'newsletter.md'), result.contentMarkdown);
|
|
465
|
-
if (result.summary) {
|
|
466
|
-
jetpack.write(path.join(runDir, 'summary.md'), result.summary + '\n');
|
|
467
|
-
}
|
|
468
|
-
jetpack.write(path.join(runDir, 'structure.json'), JSON.stringify(result.structure, null, 2));
|
|
469
|
-
jetpack.write(path.join(runDir, 'newsletter.mjml'), result.mjml || '');
|
|
470
|
-
jetpack.write(path.join(runDir, 'metadata.json'), JSON.stringify({
|
|
471
|
-
...(result.meta || {}),
|
|
472
|
-
tags: result.tags || [],
|
|
473
|
-
assets: result.assets || null,
|
|
474
|
-
}, null, 2));
|
|
475
|
-
|
|
476
|
-
// Linked blog article (when content.article.enabled). Save the full Ghostii
|
|
477
|
-
// output + the computed URL so you can review what would be published — both
|
|
478
|
-
// the raw JSON and a readable markdown view of the article body.
|
|
479
|
-
if (result.article?.article) {
|
|
480
|
-
const a = result.article.article;
|
|
481
|
-
jetpack.write(path.join(runDir, 'article.json'), JSON.stringify(result.article, null, 2));
|
|
482
|
-
jetpack.write(path.join(runDir, 'article.md'), [
|
|
483
|
-
`# ${a.title || ''}`,
|
|
484
|
-
'',
|
|
485
|
-
`> ${a.description || ''}`,
|
|
486
|
-
'',
|
|
487
|
-
`**URL:** ${result.article.url || '(none)'}`,
|
|
488
|
-
`**Published:** ${result.article.published ? 'yes' : 'no (generate-only)'}`,
|
|
489
|
-
a.headerImageUrl ? `**Header image:** ${a.headerImageUrl}` : '',
|
|
490
|
-
a.categories?.length ? `**Categories:** ${a.categories.join(', ')}` : '',
|
|
491
|
-
a.keywords?.length ? `**Keywords:** ${a.keywords.join(', ')}` : '',
|
|
492
|
-
'',
|
|
493
|
-
'---',
|
|
494
|
-
'',
|
|
495
|
-
a.body || '',
|
|
496
|
-
].filter(line => line !== undefined).join('\n'));
|
|
497
|
-
}
|
|
498
|
-
|
|
499
|
-
console.log(`\nNewsletter preview written: ${previewPath}`);
|
|
500
|
-
console.log(`Subject: ${result.subject}`);
|
|
501
|
-
console.log(`Preheader: ${result.preheader}`);
|
|
502
|
-
console.log(`Sections: ${result.structure.sections.length}`);
|
|
503
|
-
if (result.article?.article) {
|
|
504
|
-
console.log(`Article: "${result.article.article.title}" → ${result.article.url} (${result.article.published ? 'published' : 'generate-only'})`);
|
|
505
|
-
console.log(` ${path.join(runDir, 'article.md')}`);
|
|
506
|
-
}
|
|
507
|
-
if (result.meta?.totals) {
|
|
508
|
-
const t = result.meta.totals;
|
|
509
|
-
console.log(`\nRun summary:`);
|
|
510
|
-
console.log(` Total duration: ${(result.meta.totalDurationMs / 1000).toFixed(1)}s`);
|
|
511
|
-
console.log(` AI calls: ${t.aiCalls}`);
|
|
512
|
-
console.log(` Tokens: ${t.inputTokens} in / ${t.outputTokens} out (${t.totalTokens} total)`);
|
|
513
|
-
console.log(` Cost: $${t.totalCostUSD}`);
|
|
514
|
-
console.log(` Filter: provider=${result.meta.steps.filter?.provider} model=${result.meta.steps.filter?.model} (${result.meta.steps.filter?.durationMs}ms)`);
|
|
515
|
-
console.log(` Structure: provider=${result.meta.steps.structure?.provider} model=${result.meta.steps.structure?.model} (${result.meta.steps.structure?.durationMs}ms)`);
|
|
516
|
-
for (const img of result.meta.steps.images || []) {
|
|
517
|
-
console.log(` Image ${img.section}: provider=${img.provider} model=${img.model} (${img.durationMs}ms${img.fallback ? ', FALLBACK' : ''})`);
|
|
518
|
-
}
|
|
519
|
-
}
|
|
520
|
-
|
|
521
|
-
// Beehiiv draft upload already happened inside generator.generate() —
|
|
522
|
-
// newsletter.js calls beehiiv.createPost(draft) after the GH HTML upload.
|
|
523
|
-
// Look for assets.beehiivPostId in the result (null on free plan; non-null
|
|
524
|
-
// once the Beehiiv account is on Enterprise).
|
|
525
|
-
if (result.assets?.beehiivPostId) {
|
|
526
|
-
console.log(`Beehiiv: draft post created — id=${result.assets.beehiivPostId}`);
|
|
527
|
-
}
|
|
528
|
-
|
|
529
|
-
// --- Auto-open in browser (macOS) ---
|
|
530
|
-
if (env.NEWSLETTER_OPEN === '1' && process.platform === 'darwin') {
|
|
531
|
-
try {
|
|
532
|
-
execSync(`open "${previewPath}"`);
|
|
533
|
-
} catch (e) {
|
|
534
|
-
console.warn('Failed to auto-open preview:', e.message);
|
|
535
|
-
}
|
|
536
|
-
}
|
|
537
|
-
},
|
|
538
|
-
};
|
|
539
|
-
|
|
540
|
-
/**
|
|
541
|
-
* Upload the rendered HTML to Beehiiv as a draft post (never sends). Uses the
|
|
542
|
-
* v2 Posts API directly so it works against the test's stub Manager.
|
|
543
|
-
*
|
|
544
|
-
* Writes the Beehiiv response to {runDir}/beehiiv-upload.json for inspection.
|
|
545
|
-
*
|
|
546
|
-
* Required env: BEEHIIV_API_KEY
|
|
547
|
-
* Required config: marketing.newsletter.publicationId (or we fuzzy-match by brand name)
|
|
548
|
-
*/
|
|
549
|
-
async function uploadDraftToBeehiiv({ html, structure, config, runDir }) {
|
|
550
|
-
const apiKey = process.env.BEEHIIV_API_KEY;
|
|
551
|
-
|
|
552
|
-
if (!apiKey) {
|
|
553
|
-
console.warn('[beehiiv] Skipping upload — BEEHIIV_API_KEY not set');
|
|
554
|
-
return;
|
|
555
|
-
}
|
|
556
|
-
|
|
557
|
-
const BASE_URL = 'https://api.beehiiv.com/v2';
|
|
558
|
-
const headers = { 'Authorization': `Bearer ${apiKey}` };
|
|
559
|
-
|
|
560
|
-
// Resolve publication ID — config first, then fuzzy-match by brand name
|
|
561
|
-
let publicationId = config?.marketing?.newsletter?.publicationId;
|
|
562
|
-
const brandName = config?.brand?.name;
|
|
563
|
-
|
|
564
|
-
if (!publicationId && brandName) {
|
|
565
|
-
try {
|
|
566
|
-
const pubs = await fetch(`${BASE_URL}/publications?limit=100`, {
|
|
567
|
-
response: 'json',
|
|
568
|
-
headers,
|
|
569
|
-
timeout: 10000,
|
|
570
|
-
});
|
|
571
|
-
const brandLower = brandName.toLowerCase();
|
|
572
|
-
const matched = (pubs.data || []).find((p) =>
|
|
573
|
-
p.name.toLowerCase() === brandLower
|
|
574
|
-
|| p.name.toLowerCase().includes(brandLower)
|
|
575
|
-
|| brandLower.includes(p.name.toLowerCase())
|
|
576
|
-
);
|
|
577
|
-
publicationId = matched?.id;
|
|
578
|
-
} catch (e) {
|
|
579
|
-
console.error('[beehiiv] Publication lookup failed:', e.message);
|
|
580
|
-
}
|
|
581
|
-
}
|
|
582
|
-
|
|
583
|
-
if (!publicationId) {
|
|
584
|
-
console.warn(`[beehiiv] Skipping upload — could not resolve publication ID (brand=${brandName})`);
|
|
585
|
-
return;
|
|
586
|
-
}
|
|
587
|
-
|
|
588
|
-
// Build the post — always draft from the test (never sends)
|
|
589
|
-
const body = {
|
|
590
|
-
title: structure.subject || `Newsletter ${new Date().toISOString()}`,
|
|
591
|
-
status: 'draft',
|
|
592
|
-
body_content: html,
|
|
593
|
-
email_settings: {
|
|
594
|
-
subject_line: structure.subject,
|
|
595
|
-
preview_text: structure.preheader || '',
|
|
596
|
-
},
|
|
597
|
-
};
|
|
598
|
-
|
|
599
|
-
console.log(`[beehiiv] Uploading draft to publication ${publicationId}...`);
|
|
600
|
-
|
|
601
|
-
try {
|
|
602
|
-
const data = await fetch(`${BASE_URL}/publications/${publicationId}/posts`, {
|
|
603
|
-
method: 'post',
|
|
604
|
-
response: 'json',
|
|
605
|
-
headers,
|
|
606
|
-
timeout: 30000,
|
|
607
|
-
body,
|
|
608
|
-
});
|
|
609
|
-
|
|
610
|
-
if (data.data?.id) {
|
|
611
|
-
const editUrl = `https://app.beehiiv.com/posts/${data.data.id}`;
|
|
612
|
-
console.log(`[beehiiv] ✓ Draft uploaded: ${data.data.id}`);
|
|
613
|
-
console.log(`[beehiiv] Edit at: ${editUrl}`);
|
|
614
|
-
jetpack.write(path.join(runDir, 'beehiiv-upload.json'), JSON.stringify({
|
|
615
|
-
publicationId,
|
|
616
|
-
postId: data.data.id,
|
|
617
|
-
editUrl,
|
|
618
|
-
status: 'draft',
|
|
619
|
-
uploadedAt: new Date().toISOString(),
|
|
620
|
-
response: data.data,
|
|
621
|
-
}, null, 2));
|
|
622
|
-
} else {
|
|
623
|
-
console.error('[beehiiv] Upload returned no post ID:', JSON.stringify(data));
|
|
624
|
-
}
|
|
625
|
-
} catch (e) {
|
|
626
|
-
console.error('[beehiiv] Upload failed:', e.message);
|
|
627
|
-
}
|
|
628
|
-
}
|
|
629
|
-
|
|
630
|
-
// --- Helpers ---
|
|
631
|
-
|
|
632
|
-
/**
|
|
633
|
-
* GET /newsletter/sources without claiming (claimFor omitted).
|
|
634
|
-
*/
|
|
635
|
-
async function peekSources({ parentUrl, categories, limit, key }) {
|
|
636
|
-
if (!categories.length) {
|
|
637
|
-
const data = await fetch(`${parentUrl}/newsletter-sources`, {
|
|
638
|
-
method: 'get',
|
|
639
|
-
response: 'json',
|
|
640
|
-
timeout: 15000,
|
|
641
|
-
query: { limit, backendManagerKey: key },
|
|
642
|
-
});
|
|
643
|
-
|
|
644
|
-
return data.sources || [];
|
|
645
|
-
}
|
|
646
|
-
|
|
647
|
-
const all = [];
|
|
648
|
-
for (const category of categories) {
|
|
649
|
-
const data = await fetch(`${parentUrl}/newsletter-sources`, {
|
|
650
|
-
method: 'get',
|
|
651
|
-
response: 'json',
|
|
652
|
-
timeout: 15000,
|
|
653
|
-
query: { category, limit, backendManagerKey: key },
|
|
654
|
-
});
|
|
655
|
-
|
|
656
|
-
all.push(...(data.sources || []));
|
|
657
|
-
}
|
|
658
|
-
|
|
659
|
-
return all;
|
|
660
|
-
}
|
|
661
|
-
|
|
662
|
-
/**
|
|
663
|
-
* Fetch sources for an actual generation run. Either:
|
|
664
|
-
* - A specific source by id — preview only, NO claim (iterate repeatedly on the same source)
|
|
665
|
-
* - Or N per category. Fetches WITHOUT claiming by default (claim=false), so runs are
|
|
666
|
-
* repeatable; pass claim=true (NEWSLETTER_CLAIM=1) to atomically claim/consume them.
|
|
667
|
-
*
|
|
668
|
-
* When NEWSLETTER_SOURCE_ID is set, we look the source up in any status
|
|
669
|
-
* (ready, claimed, used) so you can keep iterating on it across runs without
|
|
670
|
-
* the parent server's claim mechanism marking it consumed.
|
|
671
|
-
*/
|
|
672
|
-
async function fetchSourcesForRun({ parentUrl, newsletterConfig, brandId, sourceId, key, claim = false }) {
|
|
673
|
-
if (sourceId) {
|
|
674
|
-
// Peek across ALL ready sources (no claim). Search broadly first, then
|
|
675
|
-
// fall back to any-status if needed. We never call claimFor with sourceId
|
|
676
|
-
// so the source stays available for future runs.
|
|
677
|
-
const all = await peekSources({
|
|
678
|
-
parentUrl,
|
|
679
|
-
categories: newsletterConfig.categories || [],
|
|
680
|
-
limit: 100,
|
|
681
|
-
key,
|
|
682
|
-
});
|
|
683
|
-
|
|
684
|
-
const match = all.find((s) => s.id === sourceId);
|
|
685
|
-
|
|
686
|
-
if (match) {
|
|
687
|
-
console.log(`[source-id-mode] Found ${sourceId} in ready pool, using WITHOUT claiming (iteration mode)`);
|
|
688
|
-
return [match];
|
|
689
|
-
}
|
|
690
|
-
|
|
691
|
-
// Not in ready pool — could be already claimed/used. Try the broad endpoint without category filter.
|
|
692
|
-
const broad = await peekSources({
|
|
693
|
-
parentUrl,
|
|
694
|
-
categories: [],
|
|
695
|
-
limit: 100,
|
|
696
|
-
key,
|
|
697
|
-
});
|
|
698
|
-
const matchBroad = broad.find((s) => s.id === sourceId);
|
|
699
|
-
|
|
700
|
-
if (matchBroad) {
|
|
701
|
-
console.log(`[source-id-mode] Found ${sourceId} (outside configured categories), using WITHOUT claiming`);
|
|
702
|
-
return [matchBroad];
|
|
703
|
-
}
|
|
704
|
-
|
|
705
|
-
throw new Error(`Source ${sourceId} not found. It may have been used (status: used) or never existed.`);
|
|
706
|
-
}
|
|
707
|
-
|
|
708
|
-
// Normal: claim N per category
|
|
709
|
-
const categories = newsletterConfig.categories || [];
|
|
710
|
-
|
|
711
|
-
if (!categories.length) {
|
|
712
|
-
throw new Error('marketing.newsletter.content.categories must have at least one entry');
|
|
713
|
-
}
|
|
714
|
-
|
|
715
|
-
const all = [];
|
|
716
|
-
for (const category of categories) {
|
|
717
|
-
// claimFor is what tells the parent server to mark sources consumed. Omit it
|
|
718
|
-
// (claim=false) to fetch the same sources without claiming them.
|
|
719
|
-
const query = { category, limit: 3, backendManagerKey: key };
|
|
720
|
-
if (claim) {
|
|
721
|
-
query.claimFor = brandId;
|
|
722
|
-
}
|
|
723
|
-
|
|
724
|
-
const data = await fetch(`${parentUrl}/newsletter-sources`, {
|
|
725
|
-
method: 'get',
|
|
726
|
-
response: 'json',
|
|
727
|
-
timeout: 15000,
|
|
728
|
-
query,
|
|
729
|
-
});
|
|
730
|
-
all.push(...(data.sources || []));
|
|
731
|
-
}
|
|
732
|
-
|
|
733
|
-
return all;
|
|
734
|
-
}
|
|
735
|
-
|
|
736
|
-
/**
|
|
737
|
-
* Find a previous run directory to reuse for theme-only iteration.
|
|
738
|
-
* Either honors NEWSLETTER_REUSE_RUN explicitly, or picks the most recent
|
|
739
|
-
* run-* directory under outRoot. Skips the current in-progress run dir.
|
|
740
|
-
*/
|
|
741
|
-
function resolveReuseRun({ outRoot, explicit }) {
|
|
742
|
-
if (explicit) {
|
|
743
|
-
const candidate = path.join(outRoot, explicit);
|
|
744
|
-
return jetpack.exists(candidate) ? candidate : null;
|
|
745
|
-
}
|
|
746
|
-
|
|
747
|
-
if (!jetpack.exists(outRoot)) {
|
|
748
|
-
return null;
|
|
749
|
-
}
|
|
750
|
-
|
|
751
|
-
const entries = jetpack.list(outRoot) || [];
|
|
752
|
-
const runs = entries
|
|
753
|
-
.filter((name) => name.startsWith('run-'))
|
|
754
|
-
.map((name) => ({ name, full: path.join(outRoot, name) }))
|
|
755
|
-
.filter((r) => {
|
|
756
|
-
// Must contain a structure.json to be reusable
|
|
757
|
-
return jetpack.exists(path.join(r.full, 'structure.json')) === 'file';
|
|
758
|
-
})
|
|
759
|
-
.sort((a, b) => (a.name < b.name ? 1 : -1));
|
|
760
|
-
|
|
761
|
-
return runs[0]?.full || null;
|
|
762
|
-
}
|
|
763
|
-
|
|
764
|
-
function appendClaimed(claimedFile, ids) {
|
|
765
|
-
const existing = jetpack.exists(claimedFile) ? jetpack.read(claimedFile, 'json') || [] : [];
|
|
766
|
-
const stamped = ids.map((id) => ({ id, claimedAt: Math.round(Date.now() / 1000) }));
|
|
767
|
-
jetpack.write(claimedFile, [...existing, ...stamped]);
|
|
768
|
-
}
|
|
769
|
-
|
|
770
|
-
async function releaseAll(claimedFile) {
|
|
771
|
-
if (!jetpack.exists(claimedFile)) {
|
|
772
|
-
return 0;
|
|
773
|
-
}
|
|
774
|
-
|
|
775
|
-
const entries = jetpack.read(claimedFile, 'json') || [];
|
|
776
|
-
|
|
777
|
-
if (!entries.length) {
|
|
778
|
-
return 0;
|
|
779
|
-
}
|
|
780
|
-
|
|
781
|
-
const saPath = process.env.PARENT_SERVICE_ACCOUNT_PATH;
|
|
782
|
-
|
|
783
|
-
if (!saPath) {
|
|
784
|
-
throw new Error('PARENT_SERVICE_ACCOUNT_PATH env var is required for release. Point it at the parent project service-account.json.');
|
|
785
|
-
}
|
|
786
|
-
|
|
787
|
-
// Lazy-require firebase-admin
|
|
788
|
-
const admin = require('firebase-admin');
|
|
789
|
-
|
|
790
|
-
if (!admin.apps.length) {
|
|
791
|
-
admin.initializeApp({
|
|
792
|
-
credential: admin.credential.cert(require(saPath)),
|
|
793
|
-
});
|
|
794
|
-
}
|
|
795
|
-
|
|
796
|
-
const db = admin.firestore();
|
|
797
|
-
let released = 0;
|
|
798
|
-
|
|
799
|
-
for (const entry of entries) {
|
|
800
|
-
try {
|
|
801
|
-
await db.collection('newsletter-sources').doc(entry.id).update({
|
|
802
|
-
status: 'ready',
|
|
803
|
-
usedBy: null,
|
|
804
|
-
claimedBy: null,
|
|
805
|
-
usedAt: null,
|
|
806
|
-
claimedAt: null,
|
|
807
|
-
});
|
|
808
|
-
released++;
|
|
809
|
-
} catch (e) {
|
|
810
|
-
console.warn(`Failed to release ${entry.id}: ${e.message}`);
|
|
811
|
-
}
|
|
812
|
-
}
|
|
813
|
-
|
|
814
|
-
// Clear the tracker
|
|
815
|
-
jetpack.write(claimedFile, []);
|
|
816
|
-
|
|
817
|
-
return released;
|
|
818
|
-
}
|
|
819
|
-
|