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.
Files changed (68) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/CLAUDE.md +43 -1501
  3. package/docs/admin-post-route.md +24 -0
  4. package/docs/ai-library.md +23 -0
  5. package/docs/architecture.md +31 -0
  6. package/docs/auth-hooks.md +74 -0
  7. package/docs/cli-firestore-auth.md +59 -0
  8. package/docs/cli-logs.md +67 -0
  9. package/docs/code-patterns.md +67 -0
  10. package/docs/common-operations.md +64 -0
  11. package/docs/directory-structure.md +119 -0
  12. package/docs/environment-detection.md +7 -0
  13. package/docs/file-naming.md +11 -0
  14. package/docs/marketing-campaigns.md +244 -0
  15. package/docs/marketing-fields.md +25 -0
  16. package/docs/mcp.md +95 -0
  17. package/docs/payment-system.md +325 -0
  18. package/docs/response-headers.md +7 -0
  19. package/docs/routes.md +126 -0
  20. package/docs/sanitization.md +61 -0
  21. package/docs/schemas.md +39 -0
  22. package/docs/stripe-webhook-forwarding.md +18 -0
  23. package/docs/testing.md +129 -0
  24. package/docs/usage-rate-limiting.md +67 -0
  25. package/package.json +8 -4
  26. package/src/defaults/CHANGELOG.md +15 -0
  27. package/src/defaults/CLAUDE.md +8 -4
  28. package/src/defaults/docs/README.md +17 -0
  29. package/src/defaults/test/README.md +33 -0
  30. package/src/manager/events/cron/daily/marketing-newsletter-generate.js +48 -8
  31. package/src/manager/functions/core/actions/api/admin/create-post.js +3 -27
  32. package/src/manager/helpers/settings.js +26 -7
  33. package/src/manager/helpers/utilities.js +21 -0
  34. package/src/manager/index.js +1 -1
  35. package/src/manager/libraries/ai/index.js +162 -0
  36. package/src/manager/libraries/ai/providers/anthropic.js +193 -0
  37. package/src/manager/libraries/ai/providers/claude-code.js +206 -0
  38. package/src/manager/libraries/ai/providers/openai.js +934 -0
  39. package/src/manager/libraries/disposable-domains.json +2 -0
  40. package/src/manager/libraries/email/generators/lib/filter.js +179 -0
  41. package/src/manager/libraries/email/generators/lib/image-host.js +231 -0
  42. package/src/manager/libraries/email/generators/lib/mjml-template.js +83 -0
  43. package/src/manager/libraries/email/generators/lib/structure.js +278 -0
  44. package/src/manager/libraries/email/generators/lib/svg-illustrator.js +184 -0
  45. package/src/manager/libraries/email/generators/lib/templates/classic-schema.js +63 -0
  46. package/src/manager/libraries/email/generators/lib/templates/clean.js +82 -0
  47. package/src/manager/libraries/email/generators/lib/templates/editorial-helpers.js +100 -0
  48. package/src/manager/libraries/email/generators/lib/templates/editorial.js +317 -0
  49. package/src/manager/libraries/email/generators/lib/templates/field-report-helpers.js +138 -0
  50. package/src/manager/libraries/email/generators/lib/templates/field-report.js +497 -0
  51. package/src/manager/libraries/email/generators/lib/templates/index.js +28 -0
  52. package/src/manager/libraries/email/generators/lib/templates/shared.js +534 -0
  53. package/src/manager/libraries/email/generators/newsletter.js +377 -95
  54. package/src/manager/libraries/email/marketing/index.js +5 -2
  55. package/src/manager/libraries/email/providers/beehiiv.js +7 -3
  56. package/src/manager/libraries/openai.js +13 -932
  57. package/src/manager/routes/admin/post/deduplicate-image-alts.js +52 -0
  58. package/src/manager/routes/admin/post/post.js +10 -17
  59. package/templates/_.env +4 -0
  60. package/templates/_.gitignore +1 -0
  61. package/templates/backend-manager-config.json +48 -4
  62. package/test/helpers/slugify.js +394 -0
  63. package/test/marketing/fixtures/clean.json +31 -0
  64. package/test/marketing/fixtures/editorial.json +31 -0
  65. package/test/marketing/fixtures/field-report.json +54 -0
  66. package/test/marketing/newsletter-generate.js +731 -0
  67. package/test/marketing/newsletter-templates.js +512 -0
  68. package/test/routes/admin/deduplicate-image-alts.js +190 -0
@@ -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.202",
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.2",
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.52.0",
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.8.4",
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.
@@ -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
- **Framework's own docs** (read these for deep-dives; both paths point to the same files, the absolute path works regardless of working directory):
11
- - Top-level overview: `/Users/ian/Developer/Repositories/ITW-Creative-Works/backend-manager/CLAUDE.md` (or `node_modules/backend-manager/CLAUDE.md`)
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. Generates content via AI and creates
6
- * a NEW standalone pending campaign with the real content.
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
- * After generating, advances the recurring doc's sendAt to the next occurrence.
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
- // Run the generator
59
- const generated = await generators[generator].generate(Manager, assistant, settings);
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: generated,
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 = hyphenate(alt);
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 (e) {
59
- // Fallback to main schema if method-specific doesn't exist
60
- schemaPath = path.resolve(options.dir, `${schemaFile}/index.js`);
61
- schema = loadSchema(assistant, schemaPath);
62
- assistant.log(`Settings.resolve(): Method-specific schema not found, using main schema fallback`);
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.
@@ -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/openai.js');
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