backend-manager 5.11.1 → 5.11.4
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/CLAUDE.md +1 -1
- package/docs/marketing-campaigns.md +25 -7
- package/docs/test-framework.md +2 -0
- package/package.json +1 -1
- package/src/manager/events/cron/daily/blog-auto-publisher.js +9 -0
- package/src/manager/events/cron/frequent/marketing-campaigns.js +177 -37
- package/src/manager/index.js +5 -1
- package/src/manager/libraries/content/source-resolver.js +7 -0
- package/src/manager/libraries/email/constants.js +32 -0
- package/src/manager/libraries/email/generators/lib/image-host.js +3 -4
- package/src/manager/libraries/email/generators/newsletter.js +14 -2
- package/src/test/fixtures/firebase-project/.temp/test-mode.json +1 -1
- package/src/test/fixtures/firebase-project/database-debug.log +8 -8
- package/src/test/fixtures/firebase-project/firestore-debug.log +57 -51
- package/src/test/fixtures/firebase-project/pubsub-debug.log +3 -3
- package/test/email/campaign-cron-pipeline.js +357 -98
- package/test/email/newsletter-generate.js +12 -0
- package/test/helpers/ai-schema-resolve.js +16 -11
- package/test/helpers/content/blog-auto-publisher.js +13 -0
package/CLAUDE.md
CHANGED
|
@@ -183,7 +183,7 @@ Deep references live in `docs/`. **Whenever you make a behavioral change, update
|
|
|
183
183
|
|
|
184
184
|
### Testing & CLI
|
|
185
185
|
|
|
186
|
-
- [docs/test-framework.md](docs/test-framework.md) — running, filtering, log files, test types (standalone/suite/group), context object, assertions, auth levels. **NEVER mock — test against the real emulator.** No `mockManager`/`mockAdmin`/fake `firestore`/stubbed `assistant`; every `run()` gets the real `Manager`/`assistant`/`firestore`/`http`/`accounts` — use them. Pure functions (zero I/O) are the only thing you call directly; anything touching Firestore or an external API runs for real. Real external APIs (OpenAI/PayPal/GitHub/SendGrid/Stripe) are gated behind `TEST_EXTENDED_MODE` in-source (not mocked) — opt in with `--extended` or `TEST_EXTENDED_MODE=true` (shared, unprefixed across BEM/BXM/UJM/EM; propagates to BOTH runner + emulator) — and anything an extended test creates externally must be cleaned up by the test. **Each test file `module.exports` a `{ description, type, tests }` object — NOT raw Mocha (`describe`/`it`/`beforeEach`); those globals are not injected and the file fails to load. Split tests one-file-per-concern under `test/<area>/`, never one giant `test/test.js`.** **All cleanup runs at the START of every run, never at the end** — the runner flushes the ENTIRE emulator Firestore before every run, so there's nothing to register; seed any needed fixtures in `test/_init.js`'s `setup()`, and never add a trailing cleanup step. Marketing providers (SendGrid/Beehiiv) don't need a special exception — `_test.*` emails are blocked at the validation layer so test signups never reach providers. The `_test.allow_*` carve-out exists only for the live-provider lifecycle test (`test/marketing/consent-lifecycle.js`), which manages its own teardown.
|
|
186
|
+
- [docs/test-framework.md](docs/test-framework.md) — running, filtering, log files, test types (standalone/suite/group), context object, assertions, auth levels. **NEVER mock — test against the real emulator.** No `mockManager`/`mockAdmin`/fake `firestore`/stubbed `assistant`; every `run()` gets the real `Manager`/`assistant`/`firestore`/`http`/`accounts` — use them. Pure functions (zero I/O) are the only thing you call directly; anything touching Firestore or an external API runs for real. Real external APIs (OpenAI/PayPal/GitHub/SendGrid/Stripe) are gated behind `TEST_EXTENDED_MODE` in-source (not mocked) — opt in with `--extended` or `TEST_EXTENDED_MODE=true` (shared, unprefixed across BEM/BXM/UJM/EM; propagates to BOTH runner + emulator) — and anything an extended test creates externally must be cleaned up by the test. **Each test file `module.exports` a `{ description, type, tests }` object — NOT raw Mocha (`describe`/`it`/`beforeEach`); those globals are not injected and the file fails to load. The only lifecycle hook is `cleanup` — exported `before`/`after` properties are silently IGNORED (do setup inside the tests via an idempotent helper, or in `test/_init.js`). Split tests one-file-per-concern under `test/<area>/`, never one giant `test/test.js`.** **All cleanup runs at the START of every run, never at the end** — the runner flushes the ENTIRE emulator Firestore before every run, so there's nothing to register; seed any needed fixtures in `test/_init.js`'s `setup()`, and never add a trailing cleanup step. Marketing providers (SendGrid/Beehiiv) don't need a special exception — `_test.*` emails are blocked at the validation layer so test signups never reach providers. The `_test.allow_*` carve-out exists only for the live-provider lifecycle test (`test/marketing/consent-lifecycle.js`), which manages its own teardown.
|
|
187
187
|
- [docs/test-boot-layer.md](docs/test-boot-layer.md) — the `boot/` smoke layer: framework self-test from the repo via the bundled fixture project + `BEM_TEST_BOOT_PROJECT` (BEM's analog of BXM/UJM `*_TEST_BOOT_PROJECT`)
|
|
188
188
|
- [docs/cli-firestore-auth.md](docs/cli-firestore-auth.md) — `npx mgr firestore:*` and `auth:*` commands, shared flags, examples
|
|
189
189
|
- [docs/cli-logs.md](docs/cli-logs.md) — `npx mgr logs:read` / `logs:tail` with full flag reference and built-in Cloud Function names
|
|
@@ -15,13 +15,16 @@
|
|
|
15
15
|
{
|
|
16
16
|
settings: { name, subject, preheader, content, template, sender, segments, excludeSegments, ... },
|
|
17
17
|
sendAt: 1743465600, // Unix timestamp (any format accepted, normalized on create)
|
|
18
|
-
status: 'pending', // pending | sent | failed
|
|
18
|
+
status: 'pending', // pending | processing | sent | failed
|
|
19
19
|
type: 'email', // email | push
|
|
20
20
|
recurrence: { pattern, day, hour, minute?, nth? }, // Optional — makes it recurring
|
|
21
21
|
generator: 'newsletter', // Optional — runs content generator before sending
|
|
22
22
|
recurringId: '_recurring-sale', // Present on history docs (links to parent template)
|
|
23
23
|
generatedFrom: '_recurring-newsletter', // Present on generated docs
|
|
24
24
|
results: { campaigns: {...}, newsletter: {...} },
|
|
25
|
+
processingStartedAt: 1743465610, // Set while claimed by a cron run (lease)
|
|
26
|
+
generatorAttempts: 2, // Consecutive empty generator runs (retry-cap bookkeeping)
|
|
27
|
+
error: 'Unknown generator "..."', // Set when marked failed without sending
|
|
25
28
|
metadata: { created: {...}, updated: {...} },
|
|
26
29
|
}
|
|
27
30
|
```
|
|
@@ -32,29 +35,44 @@
|
|
|
32
35
|
- **Push**: dispatches to FCM via `notification.send()` (shared library)
|
|
33
36
|
- Content is **markdown** — converted to HTML at send time. Template variables resolved before conversion.
|
|
34
37
|
|
|
38
|
+
## Claim/Lease Lifecycle
|
|
39
|
+
|
|
40
|
+
Every due campaign is **claimed** before any work happens: the cron transactionally flips `status: 'pending'` → `'processing'` (stamping `processingStartedAt`). A campaign another (overlapping) run already claimed is skipped — double-sends are impossible by construction.
|
|
41
|
+
|
|
42
|
+
- Every terminal path writes a final status: `sent`/`failed` for one-offs, back to `pending` (with an advanced `sendAt`) for recurring
|
|
43
|
+
- If a run dies mid-flight (crash, function timeout), the doc stays `processing` until the **stale-lease reclaim** flips it back to `pending` after `PROCESSING_LEASE_SECONDS` (30 min) — a natural retry backoff
|
|
44
|
+
- Unknown campaign `type`s and unknown `generator`s are marked `failed` with an `error` field (a config typo must not retry every 10 minutes forever)
|
|
45
|
+
- `PUT /marketing/campaign` only edits `pending` campaigns, so a mid-flight campaign can't be edited out from under the cron
|
|
46
|
+
|
|
35
47
|
## Recurring Campaigns
|
|
36
48
|
|
|
37
49
|
Campaigns with a `recurrence` field repeat automatically:
|
|
38
50
|
- Cron fires → creates a **history doc** (same collection, `recurringId` set) → advances `sendAt` to next occurrence
|
|
39
|
-
- Status
|
|
51
|
+
- Status returns to `pending` on the recurring template after each run, history docs are `sent`/`failed`
|
|
40
52
|
- `_` prefix on IDs groups them at top of Firestore console
|
|
41
53
|
|
|
42
54
|
Recurrence patterns: `daily`, `weekly`, `monthly`, `monthly-weekday`, `quarterly`, `yearly`
|
|
43
55
|
|
|
44
56
|
The `monthly-weekday` pattern targets the Nth weekday of each month (e.g., 2nd Wednesday). Requires `nth` (1-4) and `day` (0=Sun–6=Sat) in the recurrence object. All other patterns use simple interval addition from the current `sendAt`.
|
|
45
57
|
|
|
46
|
-
All scheduling helpers live in `constants.js` (SSOT): `nextWeekday()`, `nextNthWeekday()`, `nextMonthDay()`, `getNextOccurrence()`. The cron job imports from there — no duplicated logic.
|
|
58
|
+
All scheduling helpers live in `constants.js` (SSOT): `nextWeekday()`, `nextNthWeekday()`, `nextMonthDay()`, `getNextOccurrence()`, `getNextFutureOccurrence()`. The cron job imports from there — no duplicated logic.
|
|
59
|
+
|
|
60
|
+
**Catch-up safety:** the cron advances recurring campaigns with `getNextFutureOccurrence()`, which skips ALL missed occurrences and always lands in the future. A weekly campaign stalled for 3 weeks (cron outage, dried-up sources) sends ONE issue and resumes its schedule — never a catch-up burst of back-to-back sends.
|
|
47
61
|
|
|
48
62
|
## Generator Campaigns
|
|
49
63
|
|
|
50
64
|
Campaigns with a `generator` field (e.g. `generator: 'newsletter'`) are handled by the frequent cron inline — generate content and send in one shot when `sendAt` is due:
|
|
51
|
-
1. Frequent cron
|
|
65
|
+
1. Frequent cron claims the generator campaign past its `sendAt`
|
|
52
66
|
2. Runs the generator module (e.g., `generators/newsletter.js`)
|
|
53
67
|
3. Sends the generated content immediately
|
|
54
|
-
4. Stores a history record with generated content + send results
|
|
55
|
-
5.
|
|
68
|
+
4. Stores a history record with generated content + send results (bulky byproducts — `article`, `structure`, `mjml`, image buffers — are stripped from the stored settings)
|
|
69
|
+
5. Recurring: advances the template's `sendAt` to the next FUTURE occurrence and returns it to `pending`. One-off: finalized to `sent`/`failed` so it is never picked up again
|
|
70
|
+
|
|
71
|
+
**Empty-run retry cap:** when the generator yields nothing (no sources, filter dropped everything), the campaign returns to `pending` with `generatorAttempts` incremented and retries next run. After `GENERATOR_MAX_ATTEMPTS` (36 ≈ 6h at the 10-minute cadence) a recurring campaign skips to its next occurrence (counter reset); a one-off is marked `failed`.
|
|
72
|
+
|
|
73
|
+
**Test gating:** `generate()` short-circuits to `null` under `assistant.isTesting()` without `TEST_EXTENDED_MODE` — same convention as the marketing providers and the blog cron. A normal consumer test run never spends AI tokens or touches GitHub/Beehiiv; extended runs are production-equivalent.
|
|
56
74
|
|
|
57
|
-
In production, Beehiiv posts are published (`status: 'confirmed'`). In testing, they're forced to draft (`status: 'draft'`).
|
|
75
|
+
In production, Beehiiv posts are published (`status: 'confirmed'`). In testing, they're forced to draft (`status: 'draft'`). The report email always goes to `alerts@{brandDomain}` with all asset links; its subject adapts to whether Beehiiv upload succeeded.
|
|
58
76
|
|
|
59
77
|
## Email Rendering
|
|
60
78
|
|
package/docs/test-framework.md
CHANGED
|
@@ -236,6 +236,8 @@ Use `bem:` or `project:` prefix to filter by source. **Mirror the source path so
|
|
|
236
236
|
## Test Types
|
|
237
237
|
|
|
238
238
|
> **The runner reads each file's `module.exports` object — it does NOT inject Mocha/Jest globals.** A test file that calls `describe`/`it`/`before`/`beforeEach`/`after` at top level throws `ReferenceError: beforeEach is not defined` and shows as `Failed to load`. There is no global `assert` either — use the `assert` passed into `run({ assert })`. Every test file MUST export one of the shapes below.
|
|
239
|
+
>
|
|
240
|
+
> **The only lifecycle hook is `cleanup`** (per-test and module-level). Exporting `before`/`after`/`beforeEach` properties on the module does NOT error — the runner silently ignores them, so setup they were supposed to do never happens (this bit `helpers/ai-schema-resolve`, which failed for weeks because its fixtures were written in a `before()` that never ran). Seed disk/DB fixtures inside the tests themselves via an idempotent helper, or in `test/_init.js`'s `setup()`.
|
|
239
241
|
|
|
240
242
|
| Type | Use When | Behavior |
|
|
241
243
|
|------|----------|----------|
|
package/package.json
CHANGED
|
@@ -33,6 +33,15 @@ const {
|
|
|
33
33
|
let postId;
|
|
34
34
|
|
|
35
35
|
module.exports = async ({ Manager, assistant, context, libraries }) => {
|
|
36
|
+
// External boundary (live feed fetches + AI generation + publishing) — the
|
|
37
|
+
// cron entry gates itself like every other external call. Ungated, a normal
|
|
38
|
+
// test run harvests real feeds from the consumer's blog config, delaying the
|
|
39
|
+
// serial bm_cronDaily sequence past the usage suite's reset-usage deadline.
|
|
40
|
+
if (assistant.isTesting() && !process.env.TEST_EXTENDED_MODE) {
|
|
41
|
+
assistant.log('Blog auto-publisher skipped (test mode without TEST_EXTENDED_MODE)');
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
36
45
|
if (!Manager.config.blog?.enabled) {
|
|
37
46
|
assistant.log('Blog auto-publisher disabled in config');
|
|
38
47
|
return;
|
|
@@ -6,31 +6,79 @@
|
|
|
6
6
|
* - email: fires through mailer.sendCampaign()
|
|
7
7
|
* - push: fires through notification.send()
|
|
8
8
|
*
|
|
9
|
+
* Claim/lease lifecycle (prevents double-sends across overlapping runs):
|
|
10
|
+
* - Each due campaign is CLAIMED by transactionally flipping status
|
|
11
|
+
* 'pending' → 'processing' before any work happens. A campaign another
|
|
12
|
+
* run already claimed is skipped.
|
|
13
|
+
* - Every terminal path writes a final status: 'sent'/'failed' for
|
|
14
|
+
* one-offs, back to 'pending' (with an advanced sendAt) for recurring.
|
|
15
|
+
* - If a run dies mid-flight (crash, function timeout), the doc stays
|
|
16
|
+
* 'processing' until the stale-lease reclaim flips it back to 'pending'
|
|
17
|
+
* after PROCESSING_LEASE_SECONDS — a natural retry backoff.
|
|
18
|
+
*
|
|
9
19
|
* Generator campaigns (has `generator` field, e.g. 'newsletter'):
|
|
10
20
|
* - Runs the content generation pipeline (AI content, images, uploads)
|
|
11
21
|
* - Sends the generated content immediately
|
|
12
22
|
* - Stores a history record with the generated content + send results
|
|
13
|
-
* -
|
|
23
|
+
* - When generation yields nothing, retries next run — up to
|
|
24
|
+
* GENERATOR_MAX_ATTEMPTS total, then the occurrence is skipped
|
|
25
|
+
* (recurring) or the campaign is marked failed (one-off)
|
|
14
26
|
*
|
|
15
27
|
* Recurring campaigns (has `recurrence` field):
|
|
16
28
|
* - Creates a history doc in the same collection with results
|
|
17
|
-
* - Advances the recurring doc's sendAt to the next occurrence
|
|
18
|
-
*
|
|
29
|
+
* - Advances the recurring doc's sendAt to the next FUTURE occurrence
|
|
30
|
+
* (missed occurrences are skipped — no catch-up bursts)
|
|
31
|
+
* - Status returns to 'pending' on the recurring doc
|
|
32
|
+
*
|
|
33
|
+
* Unknown campaign types / generators are marked 'failed' (they can never
|
|
34
|
+
* succeed — usually a config typo — so retrying forever just burns runs).
|
|
19
35
|
*
|
|
20
36
|
* Runs on bm_cronFrequent (every 10 minutes).
|
|
21
37
|
*/
|
|
22
38
|
const moment = require('moment');
|
|
23
39
|
const pushid = require('pushid');
|
|
24
40
|
const notification = require('../../../libraries/notification.js');
|
|
25
|
-
const {
|
|
41
|
+
const { getNextFutureOccurrence } = require('../../../libraries/email/constants.js');
|
|
42
|
+
|
|
43
|
+
// How long a 'processing' lease is honored before the campaign of a crashed or
|
|
44
|
+
// timed-out run is reclaimed for retry. Must comfortably exceed the function
|
|
45
|
+
// timeout so an in-flight run is never reclaimed out from under itself.
|
|
46
|
+
const PROCESSING_LEASE_SECONDS = 30 * 60;
|
|
47
|
+
|
|
48
|
+
// How many times a generator campaign may yield nothing before its occurrence
|
|
49
|
+
// is skipped (recurring) or the campaign is failed (one-off). At the 10-minute
|
|
50
|
+
// cron cadence this is ~6 hours of retries.
|
|
51
|
+
const GENERATOR_MAX_ATTEMPTS = 36;
|
|
26
52
|
|
|
27
53
|
module.exports = async ({ Manager, assistant, libraries }) => {
|
|
28
54
|
const { admin } = libraries;
|
|
29
55
|
const now = Math.round(Date.now() / 1000);
|
|
56
|
+
const collection = admin.firestore().collection('marketing-campaigns');
|
|
57
|
+
|
|
58
|
+
// --- Reclaim stale processing leases (crashed or timed-out runs) ---
|
|
59
|
+
// Single-equality query + in-memory cutoff — no composite index required.
|
|
60
|
+
const processingSnapshot = await collection
|
|
61
|
+
.where('status', '==', 'processing')
|
|
62
|
+
.limit(50)
|
|
63
|
+
.get();
|
|
64
|
+
|
|
65
|
+
for (const doc of processingSnapshot.docs) {
|
|
66
|
+
const startedAt = doc.data().processingStartedAt || 0;
|
|
67
|
+
|
|
68
|
+
if (startedAt > now - PROCESSING_LEASE_SECONDS) {
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
await doc.ref.set({
|
|
73
|
+
status: 'pending',
|
|
74
|
+
metadata: { updated: stamp() },
|
|
75
|
+
}, { merge: true });
|
|
76
|
+
|
|
77
|
+
assistant.log(`Reclaimed stale processing lease on ${doc.id} (started ${moment.unix(startedAt).toISOString()})`);
|
|
78
|
+
}
|
|
30
79
|
|
|
31
|
-
// Query campaigns that are ready to send
|
|
32
|
-
const snapshot = await
|
|
33
|
-
.collection('marketing-campaigns')
|
|
80
|
+
// --- Query campaigns that are ready to send ---
|
|
81
|
+
const snapshot = await collection
|
|
34
82
|
.where('status', '==', 'pending')
|
|
35
83
|
.where('sendAt', '<=', now)
|
|
36
84
|
.limit(20)
|
|
@@ -47,9 +95,18 @@ module.exports = async ({ Manager, assistant, libraries }) => {
|
|
|
47
95
|
|
|
48
96
|
const results = await Promise.allSettled(snapshot.docs.map(async (doc) => {
|
|
49
97
|
const data = doc.data();
|
|
50
|
-
|
|
98
|
+
const { settings, type, recurrence, generator } = data;
|
|
51
99
|
const campaignId = doc.id;
|
|
52
100
|
|
|
101
|
+
// Claim the campaign before doing ANY work. If another (overlapping) run
|
|
102
|
+
// already claimed it, skip — this is what makes double-sends impossible.
|
|
103
|
+
const claimed = await claimCampaign(admin, doc, now);
|
|
104
|
+
|
|
105
|
+
if (!claimed) {
|
|
106
|
+
assistant.log(`Campaign ${campaignId} already claimed by another run, skipping`);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
|
|
53
110
|
assistant.log(`Processing campaign ${campaignId} (${type}): ${settings.name}`);
|
|
54
111
|
|
|
55
112
|
// --- Generator campaigns: generate content + send in one shot ---
|
|
@@ -59,7 +116,13 @@ module.exports = async ({ Manager, assistant, libraries }) => {
|
|
|
59
116
|
};
|
|
60
117
|
|
|
61
118
|
if (!generators[generator]) {
|
|
62
|
-
|
|
119
|
+
await doc.ref.set({
|
|
120
|
+
status: 'failed',
|
|
121
|
+
error: `Unknown generator "${generator}"`,
|
|
122
|
+
metadata: { updated: stamp() },
|
|
123
|
+
}, { merge: true });
|
|
124
|
+
|
|
125
|
+
assistant.log(`Unknown generator "${generator}" on ${campaignId} — marked failed`);
|
|
63
126
|
return;
|
|
64
127
|
}
|
|
65
128
|
|
|
@@ -72,16 +135,55 @@ module.exports = async ({ Manager, assistant, libraries }) => {
|
|
|
72
135
|
publishArticle: Manager.isProduction(),
|
|
73
136
|
});
|
|
74
137
|
|
|
138
|
+
// Nothing generated (no sources, filter dropped everything, disabled in
|
|
139
|
+
// test mode, ...). Retry next run — up to the attempts cap.
|
|
75
140
|
if (!generated) {
|
|
76
|
-
|
|
141
|
+
const attempts = (data.generatorAttempts || 0) + 1;
|
|
142
|
+
|
|
143
|
+
if (attempts >= GENERATOR_MAX_ATTEMPTS) {
|
|
144
|
+
if (recurrence) {
|
|
145
|
+
const nextSendAt = getNextFutureOccurrence(data.sendAt, recurrence, now);
|
|
146
|
+
|
|
147
|
+
await doc.ref.set({
|
|
148
|
+
status: 'pending',
|
|
149
|
+
sendAt: nextSendAt,
|
|
150
|
+
generatorAttempts: admin.firestore.FieldValue.delete(),
|
|
151
|
+
metadata: { updated: stamp() },
|
|
152
|
+
}, { merge: true });
|
|
153
|
+
|
|
154
|
+
assistant.log(`Generator "${generator}" yielded nothing ${attempts}x on ${campaignId} — skipping to next occurrence: ${moment.unix(nextSendAt).toISOString()}`);
|
|
155
|
+
} else {
|
|
156
|
+
await doc.ref.set({
|
|
157
|
+
status: 'failed',
|
|
158
|
+
error: `Generator "${generator}" yielded no content after ${attempts} attempts`,
|
|
159
|
+
metadata: { updated: stamp() },
|
|
160
|
+
}, { merge: true });
|
|
161
|
+
|
|
162
|
+
assistant.log(`Generator "${generator}" yielded nothing ${attempts}x on one-off ${campaignId} — marked failed`);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
await doc.ref.set({
|
|
169
|
+
status: 'pending',
|
|
170
|
+
generatorAttempts: attempts,
|
|
171
|
+
metadata: { updated: stamp() },
|
|
172
|
+
}, { merge: true });
|
|
173
|
+
|
|
174
|
+
assistant.log(`Generator "${generator}" returned no content for ${campaignId}, will retry next run (attempt ${attempts}/${GENERATOR_MAX_ATTEMPTS})`);
|
|
77
175
|
return;
|
|
78
176
|
}
|
|
79
177
|
|
|
178
|
+
// Strip generation byproducts: bulky debug/asset fields stay OUT of the
|
|
179
|
+
// send payload and the history doc's settings (article carries the full
|
|
180
|
+
// post body; assets/meta are stored as their own fields below).
|
|
80
181
|
const {
|
|
81
182
|
images: _images,
|
|
82
183
|
mjml: _mjml,
|
|
83
184
|
structure: _structure,
|
|
84
185
|
contentMarkdown: _contentMarkdown,
|
|
186
|
+
article: _article,
|
|
85
187
|
assets,
|
|
86
188
|
meta,
|
|
87
189
|
...generatedSettings
|
|
@@ -93,9 +195,6 @@ module.exports = async ({ Manager, assistant, libraries }) => {
|
|
|
93
195
|
const campaignResults = await email.sendCampaign({ ...generatedSettings, sendAt: 'now' });
|
|
94
196
|
const success = Object.values(campaignResults).some(r => r.success || r.sent > 0);
|
|
95
197
|
|
|
96
|
-
const nowISO = new Date().toISOString();
|
|
97
|
-
const nowUNIX = Math.round(Date.now() / 1000);
|
|
98
|
-
|
|
99
198
|
// Store history record
|
|
100
199
|
const historyId = pushid();
|
|
101
200
|
await admin.firestore().doc(`marketing-campaigns/${historyId}`).set({
|
|
@@ -107,23 +206,30 @@ module.exports = async ({ Manager, assistant, libraries }) => {
|
|
|
107
206
|
status: success ? 'sent' : 'failed',
|
|
108
207
|
results: campaignResults,
|
|
109
208
|
generatedFrom: campaignId,
|
|
110
|
-
metadata: {
|
|
111
|
-
created: { timestamp: nowISO, timestampUNIX: nowUNIX },
|
|
112
|
-
updated: { timestamp: nowISO, timestampUNIX: nowUNIX },
|
|
113
|
-
},
|
|
209
|
+
metadata: { created: stamp(), updated: stamp() },
|
|
114
210
|
});
|
|
115
211
|
|
|
116
|
-
// Advance sendAt to next occurrence
|
|
117
212
|
if (recurrence) {
|
|
118
|
-
|
|
213
|
+
// Advance sendAt to the next FUTURE occurrence
|
|
214
|
+
const nextSendAt = getNextFutureOccurrence(data.sendAt, recurrence, now);
|
|
215
|
+
|
|
119
216
|
await doc.ref.set({
|
|
217
|
+
status: 'pending',
|
|
120
218
|
sendAt: nextSendAt,
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
},
|
|
219
|
+
generatorAttempts: admin.firestore.FieldValue.delete(),
|
|
220
|
+
metadata: { updated: stamp() },
|
|
124
221
|
}, { merge: true });
|
|
222
|
+
|
|
125
223
|
assistant.log(`${success ? 'Sent' : 'Failed'} generator campaign ${campaignId}, next: ${moment.unix(nextSendAt).toISOString()}`);
|
|
126
224
|
} else {
|
|
225
|
+
// One-off: finalize so it is never picked up again
|
|
226
|
+
await doc.ref.set({
|
|
227
|
+
status: success ? 'sent' : 'failed',
|
|
228
|
+
results: campaignResults,
|
|
229
|
+
generatorAttempts: admin.firestore.FieldValue.delete(),
|
|
230
|
+
metadata: { updated: stamp() },
|
|
231
|
+
}, { merge: true });
|
|
232
|
+
|
|
127
233
|
assistant.log(`${success ? 'Sent' : 'Failed'} generator campaign ${campaignId} (one-off)`);
|
|
128
234
|
}
|
|
129
235
|
|
|
@@ -150,13 +256,17 @@ module.exports = async ({ Manager, assistant, libraries }) => {
|
|
|
150
256
|
}),
|
|
151
257
|
};
|
|
152
258
|
} else {
|
|
153
|
-
|
|
259
|
+
await doc.ref.set({
|
|
260
|
+
status: 'failed',
|
|
261
|
+
error: `Unknown campaign type "${type}"`,
|
|
262
|
+
metadata: { updated: stamp() },
|
|
263
|
+
}, { merge: true });
|
|
264
|
+
|
|
265
|
+
assistant.log(`Unknown campaign type "${type}" on ${campaignId} — marked failed`);
|
|
154
266
|
return;
|
|
155
267
|
}
|
|
156
268
|
|
|
157
269
|
const success = Object.values(campaignResults).some(r => r.success || r.sent > 0);
|
|
158
|
-
const nowISO = new Date().toISOString();
|
|
159
|
-
const nowUNIX = Math.round(Date.now() / 1000);
|
|
160
270
|
|
|
161
271
|
// --- Handle recurring vs one-off ---
|
|
162
272
|
if (recurrence) {
|
|
@@ -170,20 +280,16 @@ module.exports = async ({ Manager, assistant, libraries }) => {
|
|
|
170
280
|
status: success ? 'sent' : 'failed',
|
|
171
281
|
results: campaignResults,
|
|
172
282
|
recurringId: campaignId,
|
|
173
|
-
metadata: {
|
|
174
|
-
created: { timestamp: nowISO, timestampUNIX: nowUNIX },
|
|
175
|
-
updated: { timestamp: nowISO, timestampUNIX: nowUNIX },
|
|
176
|
-
},
|
|
283
|
+
metadata: { created: stamp(), updated: stamp() },
|
|
177
284
|
});
|
|
178
285
|
|
|
179
|
-
// Advance sendAt to next occurrence
|
|
180
|
-
const nextSendAt =
|
|
286
|
+
// Advance sendAt to the next FUTURE occurrence
|
|
287
|
+
const nextSendAt = getNextFutureOccurrence(data.sendAt, recurrence, now);
|
|
181
288
|
|
|
182
289
|
await doc.ref.set({
|
|
290
|
+
status: 'pending',
|
|
183
291
|
sendAt: nextSendAt,
|
|
184
|
-
metadata: {
|
|
185
|
-
updated: { timestamp: nowISO, timestampUNIX: nowUNIX },
|
|
186
|
-
},
|
|
292
|
+
metadata: { updated: stamp() },
|
|
187
293
|
}, { merge: true });
|
|
188
294
|
|
|
189
295
|
assistant.log(`Recurring campaign ${campaignId} ${success ? 'sent' : 'failed'}, next: ${moment.unix(nextSendAt).toISOString()}`);
|
|
@@ -192,9 +298,7 @@ module.exports = async ({ Manager, assistant, libraries }) => {
|
|
|
192
298
|
await doc.ref.set({
|
|
193
299
|
status: success ? 'sent' : 'failed',
|
|
194
300
|
results: campaignResults,
|
|
195
|
-
metadata: {
|
|
196
|
-
updated: { timestamp: nowISO, timestampUNIX: nowUNIX },
|
|
197
|
-
},
|
|
301
|
+
metadata: { updated: stamp() },
|
|
198
302
|
}, { merge: true });
|
|
199
303
|
|
|
200
304
|
assistant.log(`Campaign ${campaignId} ${success ? 'sent' : 'failed'}`);
|
|
@@ -206,9 +310,45 @@ module.exports = async ({ Manager, assistant, libraries }) => {
|
|
|
206
310
|
|
|
207
311
|
for (const r of results) {
|
|
208
312
|
if (r.status === 'rejected') {
|
|
313
|
+
// The campaign doc stays 'processing' — the stale-lease reclaim retries
|
|
314
|
+
// it after PROCESSING_LEASE_SECONDS instead of every 10 minutes.
|
|
209
315
|
assistant.error(`Failed to process campaign: ${r.reason?.message}`, r.reason);
|
|
210
316
|
}
|
|
211
317
|
}
|
|
212
318
|
|
|
213
319
|
assistant.log(`Completed! (${sent} processed, ${failed} failed)`);
|
|
214
320
|
};
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Transactionally claim a pending campaign by flipping status to 'processing'.
|
|
324
|
+
* Returns false when another run (or an earlier claim) got there first.
|
|
325
|
+
*/
|
|
326
|
+
function claimCampaign(admin, doc, now) {
|
|
327
|
+
return admin.firestore().runTransaction(async (tx) => {
|
|
328
|
+
const fresh = await tx.get(doc.ref);
|
|
329
|
+
|
|
330
|
+
if (!fresh.exists || fresh.data().status !== 'pending') {
|
|
331
|
+
return false;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
tx.set(doc.ref, {
|
|
335
|
+
status: 'processing',
|
|
336
|
+
processingStartedAt: now,
|
|
337
|
+
metadata: { updated: stamp() },
|
|
338
|
+
}, { merge: true });
|
|
339
|
+
|
|
340
|
+
return true;
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function stamp() {
|
|
345
|
+
return {
|
|
346
|
+
timestamp: new Date().toISOString(),
|
|
347
|
+
timestampUNIX: Math.round(Date.now() / 1000),
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// Exposed for tests (test/email/campaign-cron-pipeline.js) — single source of
|
|
352
|
+
// truth for the lease + retry-cap tuning.
|
|
353
|
+
module.exports.PROCESSING_LEASE_SECONDS = PROCESSING_LEASE_SECONDS;
|
|
354
|
+
module.exports.GENERATOR_MAX_ATTEMPTS = GENERATOR_MAX_ATTEMPTS;
|
package/src/manager/index.js
CHANGED
|
@@ -1126,8 +1126,12 @@ Manager.prototype.setupFunctions = function (exporter, options) {
|
|
|
1126
1126
|
.pubsub.schedule('0 0 * * *')
|
|
1127
1127
|
.onRun((context) => self.EventMiddleware({ context }).run(`${cron}/daily.js`));
|
|
1128
1128
|
|
|
1129
|
+
// Frequent cron runs the inline newsletter generator (AI structure + section
|
|
1130
|
+
// images + article + uploads) — needs the v1 max timeout and headroom for
|
|
1131
|
+
// image buffers. If a run still times out, the campaign lease reclaim in
|
|
1132
|
+
// marketing-campaigns.js retries it safely.
|
|
1129
1133
|
exporter.bm_cronFrequent =
|
|
1130
|
-
fn({memory: '
|
|
1134
|
+
fn({memory: '512MB', timeoutSeconds: 540})
|
|
1131
1135
|
.pubsub.schedule('*/10 * * * *')
|
|
1132
1136
|
.onRun((context) => self.EventMiddleware({ context }).run(`${cron}/frequent.js`));
|
|
1133
1137
|
};
|
|
@@ -526,7 +526,14 @@ function getURLContent(url) {
|
|
|
526
526
|
});
|
|
527
527
|
}
|
|
528
528
|
|
|
529
|
+
// http(s) only — a bare `new URL()` check misclassifies colon-prefixed text
|
|
530
|
+
// seeds ("AI: the future of work" parses with scheme "ai:") as URLs, which
|
|
531
|
+
// then fail to fetch and silently lose the seed.
|
|
529
532
|
function isURL(url) {
|
|
533
|
+
if (typeof url !== 'string' || !/^https?:\/\//i.test(url)) {
|
|
534
|
+
return false;
|
|
535
|
+
}
|
|
536
|
+
|
|
530
537
|
try {
|
|
531
538
|
return !!new URL(url);
|
|
532
539
|
} catch (e) {
|
|
@@ -193,6 +193,37 @@ function getNextOccurrence(currentSendAt, recurrence) {
|
|
|
193
193
|
}
|
|
194
194
|
}
|
|
195
195
|
|
|
196
|
+
/**
|
|
197
|
+
* Like getNextOccurrence, but guarantees the returned time is in the FUTURE.
|
|
198
|
+
*
|
|
199
|
+
* A recurring campaign that stalled for several periods (cron outage, sources
|
|
200
|
+
* dried up, repeated failures) advances past ALL missed occurrences instead of
|
|
201
|
+
* firing a catch-up burst — one send per cron tick until sendAt catches up.
|
|
202
|
+
*
|
|
203
|
+
* @param {number} currentSendAt - Current fire time (unix)
|
|
204
|
+
* @param {object} recurrence - { pattern, hour, day, nth?, minute? }
|
|
205
|
+
* @param {number} [now] - Unix time to advance past (defaults to Date.now())
|
|
206
|
+
* @returns {number} Next fire time strictly after `now`
|
|
207
|
+
*/
|
|
208
|
+
function getNextFutureOccurrence(currentSendAt, recurrence, now) {
|
|
209
|
+
now = now || Math.round(Date.now() / 1000);
|
|
210
|
+
|
|
211
|
+
let next = getNextOccurrence(currentSendAt, recurrence);
|
|
212
|
+
|
|
213
|
+
while (next <= now) {
|
|
214
|
+
const after = getNextOccurrence(next, recurrence);
|
|
215
|
+
|
|
216
|
+
// Safety: a recurrence must strictly advance, otherwise bail out
|
|
217
|
+
if (after <= next) {
|
|
218
|
+
break;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
next = after;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return next;
|
|
225
|
+
}
|
|
226
|
+
|
|
196
227
|
/**
|
|
197
228
|
* Convert SVG image URLs to PNG equivalents — email clients don't render SVGs.
|
|
198
229
|
* CDN naming convention: `-x.svg` -> `-1024.png`
|
|
@@ -401,4 +432,5 @@ module.exports = {
|
|
|
401
432
|
nextNthWeekday,
|
|
402
433
|
nextMonthDay,
|
|
403
434
|
getNextOccurrence,
|
|
435
|
+
getNextFutureOccurrence,
|
|
404
436
|
};
|
|
@@ -238,10 +238,9 @@ async function uploadAssets({ images, html, markdown, summary, brandId, campaign
|
|
|
238
238
|
});
|
|
239
239
|
}
|
|
240
240
|
|
|
241
|
-
// 4. Commit + push with retry.
|
|
242
|
-
//
|
|
243
|
-
//
|
|
244
|
-
// Retry with a fresh ref read + jitter handles this.
|
|
241
|
+
// 4. Commit + push with retry. Per-brand branches make ref races effectively
|
|
242
|
+
// impossible (one writer per branch), but keep a defensive retry in case
|
|
243
|
+
// the same brand ever uploads concurrently or GitHub returns a stale ref.
|
|
245
244
|
const defaultSubject = subject ? subject.trim() : `${files.length} newsletter asset${files.length === 1 ? '' : 's'}`;
|
|
246
245
|
const message = commitMessage || `[${brandId}] ${campaignId} — ${defaultSubject}`;
|
|
247
246
|
const MAX_RETRIES = 3;
|
|
@@ -69,6 +69,15 @@ const { trackContentSource, contentSourceHash, resolveSources } = require('../..
|
|
|
69
69
|
* @returns {object|null} Updated settings with content filled in, or null if unavailable
|
|
70
70
|
*/
|
|
71
71
|
async function generate(Manager, assistant, settings, opts = {}) {
|
|
72
|
+
// Same convention as the marketing providers (email/marketing/index.js):
|
|
73
|
+
// default test runs never hit the real pipeline (AI calls + GitHub commit +
|
|
74
|
+
// Beehiiv post + report email). TEST_EXTENDED_MODE opts into
|
|
75
|
+
// production-equivalent side effects.
|
|
76
|
+
if (assistant.isTesting() && !process.env.TEST_EXTENDED_MODE) {
|
|
77
|
+
assistant.log('Newsletter generator: skipped in test mode (set TEST_EXTENDED_MODE to run the real pipeline)');
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
|
|
72
81
|
const newsletterRoleConfig = Manager.config?.marketing?.newsletter;
|
|
73
82
|
const rawContent = newsletterRoleConfig?.content;
|
|
74
83
|
|
|
@@ -270,6 +279,7 @@ async function generate(Manager, assistant, settings, opts = {}) {
|
|
|
270
279
|
brand,
|
|
271
280
|
config,
|
|
272
281
|
structure,
|
|
282
|
+
sources: filteredSources,
|
|
273
283
|
publish: !!opts.publishArticle,
|
|
274
284
|
}).catch((e) => {
|
|
275
285
|
assistant.error(`Newsletter generator: linked article failed — ${e.message}`);
|
|
@@ -523,10 +533,12 @@ async function generate(Manager, assistant, settings, opts = {}) {
|
|
|
523
533
|
* @param {object} args.brand - { id, name, url, ... }
|
|
524
534
|
* @param {object} args.config - marketing.newsletter.content (tone, instructions, article.author)
|
|
525
535
|
* @param {object} args.structure - newsletter structure (sections[0] is the lead)
|
|
536
|
+
* @param {object[]} [args.sources] - The brand-fit sources that fed the issue. Their URLs are
|
|
537
|
+
* passed to admin/post as the article's `source` attribution.
|
|
526
538
|
* @param {boolean} [args.publish] - Commit the post to GitHub via admin/post. Default false.
|
|
527
539
|
* @returns {Promise<{url, slug, path, published}|null>}
|
|
528
540
|
*/
|
|
529
|
-
async function buildLinkedArticle({ Manager, assistant, brand, config, structure, publish }) {
|
|
541
|
+
async function buildLinkedArticle({ Manager, assistant, brand, config, structure, sources, publish }) {
|
|
530
542
|
const lead = structure.sections[0] || {};
|
|
531
543
|
const publicConfig = buildPublicConfig(Manager.config);
|
|
532
544
|
|
|
@@ -566,7 +578,7 @@ async function buildLinkedArticle({ Manager, assistant, brand, config, structure
|
|
|
566
578
|
}
|
|
567
579
|
|
|
568
580
|
try {
|
|
569
|
-
const sourceUrls = sources.map(s => s.url || s.id).filter(Boolean);
|
|
581
|
+
const sourceUrls = (sources || []).map(s => s.url || s.id).filter(Boolean);
|
|
570
582
|
const result = await publishArticle(assistant, {
|
|
571
583
|
brand: publicConfig,
|
|
572
584
|
article,
|
|
@@ -2,11 +2,11 @@ WARNING: A terminally deprecated method in sun.misc.Unsafe has been called
|
|
|
2
2
|
WARNING: sun.misc.Unsafe::objectFieldOffset has been called by akka.util.Unsafe (file:/Users/ian/.cache/firebase/emulators/firebase-database-emulator-v4.11.2.jar)
|
|
3
3
|
WARNING: Please consider reporting this to the maintainers of class akka.util.Unsafe
|
|
4
4
|
WARNING: sun.misc.Unsafe::objectFieldOffset will be removed in a future release
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
5
|
+
01:48:07.812 [NamespaceSystem-akka.actor.default-dispatcher-4] INFO akka.event.slf4j.Slf4jLogger - Slf4jLogger started
|
|
6
|
+
01:48:07.913 [main] INFO com.firebase.server.forge.App$ - Listening at 127.0.0.1:9000
|
|
7
|
+
01:48:18.289 [NamespaceSystem-akka.actor.default-dispatcher-4] INFO com.firebase.core.namespace.NamespaceActor - demo-backend-manager-default-rtdb successfully activated FBKV (SurveyIdle(0)) wait: 73ms, init: 0ms
|
|
8
|
+
01:48:18.324 [NamespaceSystem-blocking-namespace-operation-dispatcher-6] INFO com.firebase.core.namespace.StateManager - Namespace demo-backend-manager-default-rtdb status Active to Active
|
|
9
|
+
01:48:29.496 [Thread-0] INFO com.firebase.server.forge.App$ - Attempting graceful shutdown.
|
|
10
|
+
01:48:29.562 [NamespaceSystem-akka.actor.default-dispatcher-5] INFO com.firebase.core.namespace.Terminator$Terminator - 1 actors left to terminate: demo-backend-manager-default-rtdb
|
|
11
|
+
01:48:29.581 [NamespaceSystem-akka.actor.default-dispatcher-7] INFO com.firebase.core.namespace.NamespaceActor - stopped namespace actor for demo-backend-manager-default-rtdb
|
|
12
|
+
01:48:29.604 [Thread-0] INFO com.firebase.server.forge.App$ - Graceful shutdown complete.
|