backend-manager 5.0.202 → 5.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +61 -0
- package/CLAUDE.md +43 -1501
- package/docs/admin-post-route.md +24 -0
- package/docs/ai-library.md +23 -0
- package/docs/architecture.md +31 -0
- package/docs/auth-hooks.md +74 -0
- package/docs/cli-firestore-auth.md +59 -0
- package/docs/cli-logs.md +67 -0
- package/docs/code-patterns.md +67 -0
- package/docs/common-operations.md +64 -0
- package/docs/directory-structure.md +119 -0
- package/docs/environment-detection.md +7 -0
- package/docs/file-naming.md +11 -0
- package/docs/marketing-campaigns.md +244 -0
- package/docs/marketing-fields.md +25 -0
- package/docs/mcp.md +95 -0
- package/docs/payment-system.md +325 -0
- package/docs/response-headers.md +7 -0
- package/docs/routes.md +126 -0
- package/docs/sanitization.md +61 -0
- package/docs/schemas.md +39 -0
- package/docs/stripe-webhook-forwarding.md +18 -0
- package/docs/testing.md +129 -0
- package/docs/usage-rate-limiting.md +67 -0
- package/package.json +8 -4
- package/src/defaults/CHANGELOG.md +15 -0
- package/src/defaults/CLAUDE.md +8 -4
- package/src/defaults/docs/README.md +17 -0
- package/src/defaults/test/README.md +33 -0
- package/src/manager/events/cron/daily/marketing-newsletter-generate.js +48 -8
- package/src/manager/functions/core/actions/api/admin/create-post.js +3 -27
- package/src/manager/helpers/settings.js +26 -7
- package/src/manager/helpers/utilities.js +21 -0
- package/src/manager/index.js +1 -1
- package/src/manager/libraries/ai/index.js +162 -0
- package/src/manager/libraries/ai/providers/anthropic.js +193 -0
- package/src/manager/libraries/ai/providers/claude-code.js +206 -0
- package/src/manager/libraries/ai/providers/openai.js +934 -0
- package/src/manager/libraries/disposable-domains.json +2 -0
- package/src/manager/libraries/email/generators/lib/filter.js +179 -0
- package/src/manager/libraries/email/generators/lib/image-host.js +231 -0
- package/src/manager/libraries/email/generators/lib/mjml-template.js +83 -0
- package/src/manager/libraries/email/generators/lib/structure.js +278 -0
- package/src/manager/libraries/email/generators/lib/svg-illustrator.js +184 -0
- package/src/manager/libraries/email/generators/lib/templates/classic-schema.js +63 -0
- package/src/manager/libraries/email/generators/lib/templates/clean.js +82 -0
- package/src/manager/libraries/email/generators/lib/templates/editorial-helpers.js +100 -0
- package/src/manager/libraries/email/generators/lib/templates/editorial.js +317 -0
- package/src/manager/libraries/email/generators/lib/templates/field-report-helpers.js +138 -0
- package/src/manager/libraries/email/generators/lib/templates/field-report.js +497 -0
- package/src/manager/libraries/email/generators/lib/templates/index.js +28 -0
- package/src/manager/libraries/email/generators/lib/templates/shared.js +534 -0
- package/src/manager/libraries/email/generators/newsletter.js +377 -95
- package/src/manager/libraries/email/marketing/index.js +5 -2
- package/src/manager/libraries/email/providers/beehiiv.js +7 -3
- package/src/manager/libraries/openai.js +13 -932
- package/src/manager/routes/admin/post/deduplicate-image-alts.js +52 -0
- package/src/manager/routes/admin/post/post.js +10 -17
- package/templates/_.env +4 -0
- package/templates/_.gitignore +1 -0
- package/templates/backend-manager-config.json +48 -4
- package/test/helpers/slugify.js +394 -0
- package/test/marketing/fixtures/clean.json +31 -0
- package/test/marketing/fixtures/editorial.json +31 -0
- package/test/marketing/fixtures/field-report.json +54 -0
- package/test/marketing/newsletter-generate.js +731 -0
- package/test/marketing/newsletter-templates.js +512 -0
- package/test/routes/admin/deduplicate-image-alts.js +190 -0
package/docs/testing.md
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# Testing
|
|
2
|
+
|
|
3
|
+
## Running Tests
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
# Option 1: Two terminals
|
|
7
|
+
npx mgr emulator # Terminal 1 - keeps emulator running
|
|
8
|
+
npx mgr test # Terminal 2 - runs tests
|
|
9
|
+
|
|
10
|
+
# Option 2: Single command (auto-starts emulator)
|
|
11
|
+
npx mgr test
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Log Files
|
|
15
|
+
|
|
16
|
+
BEM CLI commands automatically save all output to log files in `functions/` while still streaming to the console:
|
|
17
|
+
- **`functions/serve.log`** — Output from `npx mgr serve` (Firebase serve)
|
|
18
|
+
- **`functions/emulator.log`** — Full emulator output (Firebase emulator + Cloud Functions logs)
|
|
19
|
+
- **`functions/test.log`** — Test runner output (when running against an existing emulator)
|
|
20
|
+
- **`functions/logs.log`** — Cloud Function logs from `npx mgr logs:read` or `npx mgr logs:tail` (raw JSON for `read`, streaming text for `tail`)
|
|
21
|
+
|
|
22
|
+
When `npx mgr test` starts its own emulator, logs go to `emulator.log` (since it delegates to the emulator command). When running against an already-running emulator, logs go to `test.log`.
|
|
23
|
+
|
|
24
|
+
These files are overwritten on each run and are gitignored (`*.log`). Use them to search for errors, debug webhook pipelines, or review full function output after a test run.
|
|
25
|
+
|
|
26
|
+
## Filtering Tests
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
npx mgr test rules/ # Run rules tests (both BEM and project)
|
|
30
|
+
npx mgr test bem:rules/ # Only BEM's rules tests
|
|
31
|
+
npx mgr test project:rules/ # Only project's rules tests
|
|
32
|
+
npx mgr test user/ admin/ # Multiple paths
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Test Locations
|
|
36
|
+
|
|
37
|
+
- **BEM core tests:** `test/`
|
|
38
|
+
- **Project tests:** `functions/test/bem/`
|
|
39
|
+
|
|
40
|
+
Use `bem:` or `project:` prefix to filter by source.
|
|
41
|
+
|
|
42
|
+
## Test Types
|
|
43
|
+
|
|
44
|
+
| Type | Use When | Behavior |
|
|
45
|
+
|------|----------|----------|
|
|
46
|
+
| Standalone | Single logical test | Runs once |
|
|
47
|
+
| Suite (`type: 'suite'`) | Sequential dependent tests | Shared state, stops on failure |
|
|
48
|
+
| Group (`type: 'group'`) | Multiple independent tests | Continues on failure |
|
|
49
|
+
|
|
50
|
+
### Standalone Test
|
|
51
|
+
|
|
52
|
+
```javascript
|
|
53
|
+
module.exports = {
|
|
54
|
+
description: 'Test name',
|
|
55
|
+
auth: 'none', // none, user, admin, premium-active, premium-expired
|
|
56
|
+
timeout: 10000,
|
|
57
|
+
async run({ http, assert, accounts, firestore, state, waitFor }) { },
|
|
58
|
+
async cleanup({ ... }) { }, // Optional
|
|
59
|
+
};
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
### Suite (Sequential with Shared State)
|
|
63
|
+
|
|
64
|
+
```javascript
|
|
65
|
+
module.exports = {
|
|
66
|
+
description: 'Suite name',
|
|
67
|
+
type: 'suite',
|
|
68
|
+
tests: [
|
|
69
|
+
{ name: 'step-1', async run({ state }) { state.value = 'shared'; } },
|
|
70
|
+
{ name: 'step-2', async run({ state }) { /* state.value available */ } },
|
|
71
|
+
],
|
|
72
|
+
};
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### Group (Independent Tests)
|
|
76
|
+
|
|
77
|
+
```javascript
|
|
78
|
+
module.exports = {
|
|
79
|
+
description: 'Group name',
|
|
80
|
+
type: 'group',
|
|
81
|
+
tests: [
|
|
82
|
+
{ name: 'test-1', auth: 'admin', async run({ http, assert }) { } },
|
|
83
|
+
{ name: 'test-2', auth: 'none', async run({ http, assert }) { } },
|
|
84
|
+
],
|
|
85
|
+
};
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## Context Object
|
|
89
|
+
|
|
90
|
+
| Property | Description |
|
|
91
|
+
|----------|-------------|
|
|
92
|
+
| `http` | HTTP client (`http.command()`, `http.as('admin').command()`) |
|
|
93
|
+
| `assert` | Assertion helpers (see below) |
|
|
94
|
+
| `accounts` | Test accounts `{ basic, admin, premium-active, ... }` |
|
|
95
|
+
| `firestore` | Direct DB access (`get`, `set`, `delete`, `exists`) |
|
|
96
|
+
| `state` | Shared state (suites only) |
|
|
97
|
+
| `waitFor` | Polling helper `waitFor(condition, timeout, interval)` |
|
|
98
|
+
|
|
99
|
+
## Assert Methods
|
|
100
|
+
|
|
101
|
+
```javascript
|
|
102
|
+
assert.ok(value, message) // Truthy
|
|
103
|
+
assert.equal(a, b, message) // Strict equality
|
|
104
|
+
assert.notEqual(a, b, message) // Not equal
|
|
105
|
+
assert.deepEqual(a, b, message) // Deep equality
|
|
106
|
+
assert.match(value, /regex/, message) // Regex match
|
|
107
|
+
assert.isSuccess(response, message) // Response success
|
|
108
|
+
assert.isError(response, code, message) // Response error with code
|
|
109
|
+
assert.hasProperty(obj, 'path.to.prop', msg) // Property exists
|
|
110
|
+
assert.propertyEquals(obj, 'path', value, msg) // Property value
|
|
111
|
+
assert.isType(value, 'string', message) // Type check
|
|
112
|
+
assert.contains(array, value, message) // Array includes
|
|
113
|
+
assert.inRange(value, min, max, message) // Number range
|
|
114
|
+
assert.fail(message) // Explicit fail
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## Auth Levels
|
|
118
|
+
|
|
119
|
+
`none`, `user`/`basic`, `admin`, `premium-active`, `premium-expired`
|
|
120
|
+
|
|
121
|
+
## Key Test Files
|
|
122
|
+
|
|
123
|
+
| File | Purpose |
|
|
124
|
+
|------|---------|
|
|
125
|
+
| `src/test/runner.js` | Test runner |
|
|
126
|
+
| `test/` | BEM core tests |
|
|
127
|
+
| `src/test/utils/assertions.js` | Assert helpers |
|
|
128
|
+
| `src/test/utils/http-client.js` | HTTP client |
|
|
129
|
+
| `src/test/test-accounts.js` | Test account definitions |
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# Usage & Rate Limiting
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
Usage is tracked per-metric (e.g., `requests`, `sponsorships`) with four fields:
|
|
6
|
+
- `monthly`: Current month's count, reset on the 1st of each month by cron
|
|
7
|
+
- `daily`: Current day's count, reset every day by cron
|
|
8
|
+
- `total`: All-time count, never resets
|
|
9
|
+
- `last`: Object with `id`, `timestamp`, `timestampUNIX` of the last usage event
|
|
10
|
+
|
|
11
|
+
## Limits & Daily Caps
|
|
12
|
+
|
|
13
|
+
Limits are always specified as **monthly** values in product config (e.g., `limits.requests = 100` means 100/month).
|
|
14
|
+
|
|
15
|
+
By default, limits are enforced with **daily caps** to prevent users from burning their entire monthly quota in a single day. Two checks are applied:
|
|
16
|
+
|
|
17
|
+
1. **Flat daily cap**: `ceil(monthlyLimit / daysInMonth)` — max uses per day
|
|
18
|
+
- e.g., 100/month in a 31-day month = `ceil(100/31)` = 4/day
|
|
19
|
+
2. **Proportional monthly cap**: `ceil(monthlyLimit * dayOfMonth / daysInMonth)` — running total
|
|
20
|
+
- Prevents accumulating too much too fast even within daily limits
|
|
21
|
+
- e.g., Day 15 of a 30-day month with 100/month limit = max 50 used so far
|
|
22
|
+
|
|
23
|
+
Products can opt out of daily caps by setting `rateLimit: 'monthly'` (default is `'daily'`):
|
|
24
|
+
|
|
25
|
+
```json
|
|
26
|
+
{
|
|
27
|
+
"id": "basic",
|
|
28
|
+
"limits": { "requests": 100 },
|
|
29
|
+
"rateLimit": "monthly"
|
|
30
|
+
}
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Proxy Usage (setUser + Mirrors)
|
|
34
|
+
|
|
35
|
+
Sometimes usage must be billed to a different user than the one making the request (e.g., anonymous visitors consuming an agent owner's credits). Use `setUser()` to swap the target and `addMirror()` / `setMirrors()` to write usage to additional Firestore docs:
|
|
36
|
+
|
|
37
|
+
```js
|
|
38
|
+
// Switch usage target to the agent owner (fetches their user doc)
|
|
39
|
+
await usage.setUser(ownerUid);
|
|
40
|
+
|
|
41
|
+
// Also write usage data to the agent doc
|
|
42
|
+
usage.addMirror(`agents/${agentId}`);
|
|
43
|
+
|
|
44
|
+
// Now validate, increment, and update all operate on the owner's data
|
|
45
|
+
// update() writes to users/{ownerUid} AND agents/{agentId} in parallel
|
|
46
|
+
await usage.validate('credits');
|
|
47
|
+
usage.increment('credits');
|
|
48
|
+
await usage.update();
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
**Methods:**
|
|
52
|
+
- `setUser(uid)` — async, fetches `users/{uid}` from Firestore, replaces `self.user`, sets `useUnauthenticatedStorage = false`
|
|
53
|
+
- `setMirrors(paths)` — sync, overwrites the mirror array with the given paths
|
|
54
|
+
- `addMirror(path)` — sync, appends a single path to the mirror array
|
|
55
|
+
|
|
56
|
+
Mirrors are write-only — `update()` writes `{ usage: self.user.usage }` (merge) to each mirror path. No reads are performed on mirrors.
|
|
57
|
+
|
|
58
|
+
## Reset Schedule
|
|
59
|
+
|
|
60
|
+
| Target | Frequency | What happens |
|
|
61
|
+
|--------|-----------|-------------|
|
|
62
|
+
| Local storage | Daily | Cleared entirely |
|
|
63
|
+
| `usage` collection (unauthenticated) | Daily | Deleted entirely |
|
|
64
|
+
| User doc `usage.*.daily` (authenticated) | Daily | Reset to 0 |
|
|
65
|
+
| User doc `usage.*.monthly` (authenticated) | Monthly (1st) | Reset to 0 |
|
|
66
|
+
|
|
67
|
+
The daily cron (`reset-usage.js`) runs at midnight UTC. It collects all users with non-zero counters across all metrics, then performs a single write per user to reset daily (and monthly on the 1st).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "backend-manager",
|
|
3
|
-
"version": "5.0
|
|
3
|
+
"version": "5.1.0",
|
|
4
4
|
"description": "Quick tools for developing Firebase functions",
|
|
5
5
|
"main": "src/manager/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -49,15 +49,18 @@
|
|
|
49
49
|
}
|
|
50
50
|
},
|
|
51
51
|
"dependencies": {
|
|
52
|
+
"@anthropic-ai/claude-agent-sdk": "^0.2.140",
|
|
53
|
+
"@anthropic-ai/sdk": "^0.95.2",
|
|
52
54
|
"@firebase/rules-unit-testing": "^5.0.1",
|
|
53
55
|
"@google-cloud/firestore": "^7.11.6",
|
|
54
56
|
"@google-cloud/pubsub": "^5.3.0",
|
|
55
57
|
"@google-cloud/storage": "^7.19.0",
|
|
56
|
-
"@inquirer/prompts": "^8.4.
|
|
58
|
+
"@inquirer/prompts": "^8.4.3",
|
|
57
59
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
58
60
|
"@octokit/rest": "^22.0.1",
|
|
61
|
+
"@resvg/resvg-js": "^2.6.2",
|
|
59
62
|
"@sendgrid/mail": "^8.1.6",
|
|
60
|
-
"@sentry/node": "^10.
|
|
63
|
+
"@sentry/node": "^10.53.1",
|
|
61
64
|
"body-parser": "^2.2.2",
|
|
62
65
|
"busboy": "^1.6.0",
|
|
63
66
|
"chalk": "^5.6.2",
|
|
@@ -75,6 +78,7 @@
|
|
|
75
78
|
"mailchimp-api-v3": "^1.15.0",
|
|
76
79
|
"markdown-it": "^14.1.1",
|
|
77
80
|
"mime-types": "^3.0.2",
|
|
81
|
+
"mjml": "^5.2.1",
|
|
78
82
|
"moment": "^2.30.1",
|
|
79
83
|
"nanoid": "^5.1.11",
|
|
80
84
|
"node-powertools": "^3.0.0",
|
|
@@ -87,7 +91,7 @@
|
|
|
87
91
|
"wonderful-fetch": "^2.0.5",
|
|
88
92
|
"wonderful-log": "^1.0.7",
|
|
89
93
|
"wonderful-version": "^1.3.2",
|
|
90
|
-
"yaml": "^2.
|
|
94
|
+
"yaml": "^2.9.0",
|
|
91
95
|
"yargs": "^18.0.0"
|
|
92
96
|
},
|
|
93
97
|
"devDependencies": {
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# CHANGELOG
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
|
6
|
+
|
|
7
|
+
## Changelog Categories
|
|
8
|
+
|
|
9
|
+
- `BREAKING` for breaking changes.
|
|
10
|
+
- `Added` for new features.
|
|
11
|
+
- `Changed` for changes in existing functionality.
|
|
12
|
+
- `Deprecated` for soon-to-be removed features.
|
|
13
|
+
- `Removed` for now removed features.
|
|
14
|
+
- `Fixed` for any bug fixes.
|
|
15
|
+
- `Security` in case of vulnerabilities.
|
package/src/defaults/CLAUDE.md
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
# ========== Default Values ==========
|
|
2
2
|
# Backend Manager (BEM) — consumer project
|
|
3
3
|
|
|
4
|
-
> **Auto-managed file.** Everything between `# ========== Default Values ==========` and `# ========== Custom Values ==========` is owned by `backend-manager` and rewritten on every `npx mgr setup`. Put your own project-specific notes BELOW the `Custom Values` marker — that section is preserved verbatim across setups.
|
|
5
|
-
|
|
6
4
|
## Framework
|
|
7
5
|
|
|
8
6
|
This project consumes **Backend Manager** (BEM) — a comprehensive framework for building modern Firebase Cloud Functions backends. BEM provides a single `Manager.init(exports, {...})` bootstrap that wires built-in functions (`bm_api`, auth events, cron jobs), helper classes (Assistant, User, Analytics, Usage, Middleware, Settings, Utilities, Metadata), payment processor integrations (Stripe / PayPal), Firestore-trigger pipelines, and a deploy/emulator/watch tooling pipeline.
|
|
9
7
|
|
|
10
|
-
|
|
11
|
-
|
|
8
|
+
## 🚨 READ THE FRAMEWORK DOCS FIRST
|
|
9
|
+
|
|
10
|
+
**Before doing ANY work on this codebase, Claude MUST read the framework documentation — that is where the architecture, conventions, APIs, and gotchas live. Skipping these will result in solutions that conflict with framework patterns.**
|
|
11
|
+
|
|
12
|
+
**Required reading:**
|
|
13
|
+
- **`node_modules/backend-manager/CLAUDE.md`** — full framework reference (single comprehensive file; not yet split into per-subsystem docs)
|
|
12
14
|
|
|
13
15
|
## Quick start
|
|
14
16
|
|
|
@@ -66,6 +68,8 @@ After `Manager.init()`, the Manager instance exposes factory methods:
|
|
|
66
68
|
|
|
67
69
|
Auth events, payment-webhook transitions, and cron jobs are wired automatically — hook into them by exporting from `functions/hooks/<area>/<event>.js`.
|
|
68
70
|
|
|
71
|
+
<!-- Everything above this marker is owned by the framework and rewritten on every `npx mgr setup`. Add your project-specific notes below — they are preserved across setups. -->
|
|
72
|
+
|
|
69
73
|
# ========== Custom Values ==========
|
|
70
74
|
|
|
71
75
|
## Project-specific notes
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# Project docs
|
|
2
|
+
|
|
3
|
+
Per-subsystem deep references live here. Keep `CLAUDE.md` short — it should read as a **table of contents** that points at files in this directory.
|
|
4
|
+
|
|
5
|
+
## Pattern
|
|
6
|
+
|
|
7
|
+
When you find yourself adding more than a paragraph to `CLAUDE.md`, create a new `docs/<topic>.md` instead and link to it from `CLAUDE.md`. Goal: the project's `CLAUDE.md` stays under ~250 lines.
|
|
8
|
+
|
|
9
|
+
Examples of good `docs/*.md` topics:
|
|
10
|
+
- Subsystem deep-dives (one per area of the codebase)
|
|
11
|
+
- Architectural decisions / "why we built it this way"
|
|
12
|
+
- Defaults tables, behavior matrices, edge cases
|
|
13
|
+
- Setup walkthroughs that don't belong in `README.md`
|
|
14
|
+
|
|
15
|
+
## See also
|
|
16
|
+
|
|
17
|
+
`node_modules/backend-manager/CLAUDE.md` is the framework's own overview.
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Project tests
|
|
2
|
+
|
|
3
|
+
Drop your project test suites here. The framework auto-runs them alongside its own when you run `npx mgr test`.
|
|
4
|
+
|
|
5
|
+
## Layout
|
|
6
|
+
|
|
7
|
+
Match the framework's layout — Backend Manager's test runner discovers files by the directory they sit in. Mirror the same per-area split as the framework's own `test/` (see `node_modules/backend-manager/test/`):
|
|
8
|
+
|
|
9
|
+
| Directory | Use for |
|
|
10
|
+
|---|---|
|
|
11
|
+
| `test/routes/` | Custom HTTP route handlers (`functions/routes/<verb>/<path>.js`) |
|
|
12
|
+
| `test/events/` | Pub/Sub / Firestore-trigger handlers |
|
|
13
|
+
| `test/helpers/` | Shared test utilities for your project |
|
|
14
|
+
| `test/fixtures/` | Static test data (JSON, sample docs) |
|
|
15
|
+
| `test/_init/` | Per-suite setup (Firestore seed data, user accounts) |
|
|
16
|
+
|
|
17
|
+
Tests run inside the Firebase emulator. Use the BEM helpers (`assistant`, admin SDK, fixture loaders) instead of mocking — `npx mgr emulator` boots the same environment the tests run against.
|
|
18
|
+
|
|
19
|
+
## Quick example
|
|
20
|
+
|
|
21
|
+
```js
|
|
22
|
+
// test/routes/hello.test.js
|
|
23
|
+
module.exports = {
|
|
24
|
+
'GET /hello returns ok': async ({ http }) => {
|
|
25
|
+
const res = await http.get('hello');
|
|
26
|
+
if (res.status !== 200) throw new Error('expected 200');
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## See also
|
|
32
|
+
|
|
33
|
+
The framework's own test suites at `node_modules/backend-manager/test/` are the canonical reference for how each layer is structured.
|
|
@@ -2,13 +2,29 @@
|
|
|
2
2
|
* Newsletter pre-generation cron job
|
|
3
3
|
*
|
|
4
4
|
* Runs daily. Looks for generator campaigns (e.g., _recurring-newsletter)
|
|
5
|
-
* with sendAt within the next 24 hours.
|
|
6
|
-
*
|
|
5
|
+
* with sendAt within the next 24 hours. For each due campaign it runs the
|
|
6
|
+
* FULL pipeline:
|
|
7
|
+
*
|
|
8
|
+
* 1. Fetch + filter sources from parent server (per brand category set)
|
|
9
|
+
* 2. AI authors structured content (subject, sections/dispatches, signoff, ...)
|
|
10
|
+
* 3. AI authors per-section SVG, rasterized to PNG
|
|
11
|
+
* 4. Upload PNGs to itw-creative-works/newsletter-assets/{brandId}/{newId}/
|
|
12
|
+
* and render MJML → email-safe HTML embedding those URLs
|
|
13
|
+
* 5. Upload the rendered newsletter.html into the same folder so the issue
|
|
14
|
+
* has a browseable, downloadable archive (and a paste-into-Beehiiv URL)
|
|
15
|
+
* 6. Create a NEW pending campaign doc with the generated content + asset URLs
|
|
16
|
+
* 7. Advance the recurring template's sendAt to the next occurrence
|
|
7
17
|
*
|
|
8
18
|
* The generated campaign appears on the calendar for review.
|
|
9
19
|
* The frequent cron picks it up and sends it when sendAt is due.
|
|
10
20
|
*
|
|
11
|
-
*
|
|
21
|
+
* Generated doc shape (marketing-campaigns/{newId}):
|
|
22
|
+
* {
|
|
23
|
+
* settings: { ...generated content, subject, contentHtml, ... },
|
|
24
|
+
* assets: { folderUrl, htmlUrl, imageUrls, campaignId },
|
|
25
|
+
* meta: { telemetry — tokens, cost, durations, source scores },
|
|
26
|
+
* type, sendAt, status: 'pending', generatedFrom, metadata
|
|
27
|
+
* }
|
|
12
28
|
*
|
|
13
29
|
* Runs on bm_cronDaily.
|
|
14
30
|
*/
|
|
@@ -55,21 +71,38 @@ module.exports = async ({ Manager, assistant, libraries }) => {
|
|
|
55
71
|
|
|
56
72
|
assistant.log(`Generating content for ${campaignId} (${generator}): ${settings.name}`);
|
|
57
73
|
|
|
58
|
-
//
|
|
59
|
-
|
|
74
|
+
// Reserve the new doc ID UP FRONT so the generator can use it as the
|
|
75
|
+
// GitHub folder name. The asset URLs (raw.githubusercontent.com/.../{newId}/...)
|
|
76
|
+
// get baked into the rendered HTML, and we want those URLs to match the
|
|
77
|
+
// Firestore doc that hosts the campaign. Stable, predictable, browseable.
|
|
78
|
+
const newId = pushid();
|
|
79
|
+
|
|
80
|
+
// Run the generator with imageHost forced to 'github' (production cron
|
|
81
|
+
// path always uploads — that's what "production" means here) and the
|
|
82
|
+
// campaignId pinned so all assets land in marketing-campaigns/{newId}/'s
|
|
83
|
+
// matching folder.
|
|
84
|
+
const generated = await generators[generator].generate(Manager, assistant, settings, {
|
|
85
|
+
campaignId: newId,
|
|
86
|
+
imageHost: 'github',
|
|
87
|
+
});
|
|
60
88
|
|
|
61
89
|
if (!generated) {
|
|
62
90
|
assistant.log(`Generator "${generator}" returned no content for ${campaignId}, skipping`);
|
|
63
91
|
continue;
|
|
64
92
|
}
|
|
65
93
|
|
|
66
|
-
// Create a new standalone campaign with the generated content
|
|
67
|
-
const newId = pushid();
|
|
68
94
|
const nowISO = new Date().toISOString();
|
|
69
95
|
const nowUNIX = Math.round(Date.now() / 1000);
|
|
70
96
|
|
|
97
|
+
// Strip non-serializable fields out of the generator's return before
|
|
98
|
+
// writing to Firestore. `images` is an array of PNG Buffers (not safe
|
|
99
|
+
// to persist) and `mjml` is a raw template string that pollutes the doc.
|
|
100
|
+
const { images: _images, mjml: _mjml, assets, meta, ...campaignSettings } = generated;
|
|
101
|
+
|
|
71
102
|
await admin.firestore().doc(`marketing-campaigns/${newId}`).set({
|
|
72
|
-
settings:
|
|
103
|
+
settings: campaignSettings,
|
|
104
|
+
assets: assets || null, // { folderUrl, htmlUrl, imageUrls, campaignId } or null
|
|
105
|
+
meta: meta || null, // tokens, cost, durations, source scores
|
|
73
106
|
type,
|
|
74
107
|
sendAt: data.sendAt,
|
|
75
108
|
status: 'pending',
|
|
@@ -81,6 +114,13 @@ module.exports = async ({ Manager, assistant, libraries }) => {
|
|
|
81
114
|
});
|
|
82
115
|
|
|
83
116
|
assistant.log(`Created campaign ${newId} from generator ${campaignId}: "${generated.subject}"`);
|
|
117
|
+
if (assets?.htmlUrl) {
|
|
118
|
+
assistant.log(` HTML: ${assets.htmlUrl}`);
|
|
119
|
+
assistant.log(` Folder: ${assets.folderUrl}`);
|
|
120
|
+
}
|
|
121
|
+
if (assets?.beehiivPostId) {
|
|
122
|
+
assistant.log(` Beehiiv: draft post ${assets.beehiivPostId}`);
|
|
123
|
+
}
|
|
84
124
|
|
|
85
125
|
// Advance the recurring doc's sendAt to the next occurrence
|
|
86
126
|
if (recurrence) {
|
|
@@ -55,20 +55,8 @@ Module.prototype.main = function () {
|
|
|
55
55
|
return reject(assistant.errorify(`Missing required parameter: body`, {code: 400}));
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
// Fix required values
|
|
59
|
-
payload.data.payload.url = payload.data.payload.url
|
|
60
|
-
// Replace blog/
|
|
61
|
-
.replace(/blog\//ig, '')
|
|
62
|
-
// Remove leading and trailing slashes
|
|
63
|
-
.replace(/^\/|\/$/g, '')
|
|
64
|
-
// Replace anything that's not a letter or number with a hyphen
|
|
65
|
-
.replace(/[^a-zA-Z0-9]/g, '-')
|
|
66
|
-
// Remove multiple hyphens
|
|
67
|
-
.replace(/-+/g, '-')
|
|
68
|
-
// Remove leading and trailing hyphens
|
|
69
|
-
.replace(/^-+|-+$/g, '')
|
|
70
|
-
// Lowercase
|
|
71
|
-
.toLowerCase();
|
|
58
|
+
// Fix required values — strip blog/ prefix then slugify (slugify handles slashes/special chars)
|
|
59
|
+
payload.data.payload.url = Manager.Utilities().slugify(payload.data.payload.url.replace(/blog\//ig, ''));
|
|
72
60
|
|
|
73
61
|
// Fix body
|
|
74
62
|
payload.data.payload.body = payload.data.payload.body
|
|
@@ -207,7 +195,7 @@ Module.prototype.downloadImage = function (src, alt) {
|
|
|
207
195
|
|
|
208
196
|
return new Promise(async function(resolve, reject) {
|
|
209
197
|
// Log
|
|
210
|
-
const hyphenated =
|
|
198
|
+
const hyphenated = Manager.Utilities().slugify(alt);
|
|
211
199
|
|
|
212
200
|
// Log
|
|
213
201
|
assistant.log(`downloadImage(): src=${src}, alt=${alt}, hyphenated=${hyphenated}`);
|
|
@@ -370,16 +358,4 @@ function formatClone(payload) {
|
|
|
370
358
|
return payload;
|
|
371
359
|
}
|
|
372
360
|
|
|
373
|
-
function hyphenate(s) {
|
|
374
|
-
return s
|
|
375
|
-
// Remove everything that is not a letter or a number
|
|
376
|
-
.replace(/[^a-zA-Z0-9]/g, '-')
|
|
377
|
-
// Replace multiple hyphens with a single hyphen
|
|
378
|
-
.replace(/-+/g, '-')
|
|
379
|
-
// Remove leading and trailing hyphens
|
|
380
|
-
.replace(/^-|-$/g, '')
|
|
381
|
-
// Lowercase
|
|
382
|
-
.toLowerCase();
|
|
383
|
-
}
|
|
384
|
-
|
|
385
361
|
module.exports = Module;
|
|
@@ -47,19 +47,38 @@ Settings.prototype.resolve = function (assistant, schema, settings, options) {
|
|
|
47
47
|
const method = (assistant?.request?.method || '').toLowerCase();
|
|
48
48
|
const methodFile = `${method}.js`;
|
|
49
49
|
const schemaFile = options.schema.replace('.js', '');
|
|
50
|
-
let schemaPath;
|
|
51
50
|
|
|
52
|
-
// First try method-specific schema (e.g., test/get.js, test/post.js)
|
|
53
51
|
const methodSchemaPath = path.resolve(options.dir, `${schemaFile}/${methodFile}`);
|
|
52
|
+
const indexSchemaPath = path.resolve(options.dir, `${schemaFile}/index.js`);
|
|
53
|
+
|
|
54
|
+
// Helper: only fall back when THIS specific file is missing.
|
|
55
|
+
// If the file exists but throws (syntax error, runtime error, etc.) we re-throw
|
|
56
|
+
// so the real problem surfaces instead of being masked by a misleading fallback.
|
|
57
|
+
const isMissingModule = (err, expectedPath) => err
|
|
58
|
+
&& err.code === 'MODULE_NOT_FOUND'
|
|
59
|
+
&& typeof err.message === 'string'
|
|
60
|
+
&& err.message.includes(expectedPath);
|
|
54
61
|
|
|
55
62
|
try {
|
|
56
63
|
schema = loadSchema(assistant, methodSchemaPath);
|
|
57
64
|
assistant.log(`Settings.resolve(): Loaded method-specific schema: ${schemaFile}/${methodFile}`);
|
|
58
|
-
} catch (
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
65
|
+
} catch (methodErr) {
|
|
66
|
+
if (!isMissingModule(methodErr, methodSchemaPath)) {
|
|
67
|
+
throw methodErr;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
schema = loadSchema(assistant, indexSchemaPath);
|
|
72
|
+
assistant.log(`Settings.resolve(): Method-specific schema not found, using main schema fallback`);
|
|
73
|
+
} catch (indexErr) {
|
|
74
|
+
if (!isMissingModule(indexErr, indexSchemaPath)) {
|
|
75
|
+
throw indexErr;
|
|
76
|
+
}
|
|
77
|
+
throw assistant.errorify(
|
|
78
|
+
`No schema for ${method.toUpperCase()} request: expected ${schemaFile}/${methodFile} or ${schemaFile}/index.js`,
|
|
79
|
+
{code: 500},
|
|
80
|
+
);
|
|
81
|
+
}
|
|
63
82
|
}
|
|
64
83
|
}
|
|
65
84
|
|
|
@@ -468,6 +468,27 @@ Utilities.prototype.get = function (docPath, options) {
|
|
|
468
468
|
});
|
|
469
469
|
};
|
|
470
470
|
|
|
471
|
+
/**
|
|
472
|
+
* Convert a string into a URL-safe slug.
|
|
473
|
+
* Strips all non-alphanumeric characters, collapses runs of hyphens, lowercases.
|
|
474
|
+
* Canonical slug builder — share this between BEM admin/post and any consumer
|
|
475
|
+
* that needs to predict the resulting URL (e.g. sponsorship platform).
|
|
476
|
+
*
|
|
477
|
+
* @param {string} input - The string to slugify
|
|
478
|
+
* @returns {string} URL-safe slug (or empty string for non-string input)
|
|
479
|
+
*/
|
|
480
|
+
Utilities.prototype.slugify = function (input) {
|
|
481
|
+
if (typeof input !== 'string') {
|
|
482
|
+
return '';
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
return input
|
|
486
|
+
.replace(/[^a-zA-Z0-9]/g, '-')
|
|
487
|
+
.replace(/-+/g, '-')
|
|
488
|
+
.replace(/^-+|-+$/g, '')
|
|
489
|
+
.toLowerCase();
|
|
490
|
+
};
|
|
491
|
+
|
|
471
492
|
/**
|
|
472
493
|
* Sanitize input by stripping HTML tags and trimming strings.
|
|
473
494
|
* Accepts any data type — walks objects/arrays recursively.
|
package/src/manager/index.js
CHANGED
|
@@ -558,7 +558,7 @@ Manager.prototype.Email = function (assistant) {
|
|
|
558
558
|
|
|
559
559
|
Manager.prototype.AI = function (assistant, key) {
|
|
560
560
|
const self = this;
|
|
561
|
-
self.libraries.AI = self.libraries.AI || require('./libraries/
|
|
561
|
+
self.libraries.AI = self.libraries.AI || require('./libraries/ai/index.js');
|
|
562
562
|
return new self.libraries.AI(assistant, key);
|
|
563
563
|
};
|
|
564
564
|
|