backend-manager 5.1.2 → 5.2.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/.claude/settings.local.json +12 -0
- package/CHANGELOG.md +52 -0
- package/CLAUDE.md +2 -1
- package/README.md +30 -0
- package/docs/common-mistakes.md +1 -0
- package/docs/consent.md +333 -0
- package/docs/marketing-campaigns.md +41 -4
- package/docs/testing.md +81 -0
- package/package.json +1 -1
- package/src/cli/commands/emulator.js +62 -9
- package/src/cli/commands/serve.js +73 -7
- package/src/cli/commands/test.js +65 -1
- package/src/cli/commands/watch.js +15 -3
- package/src/defaults/CLAUDE.md +7 -5
- package/src/manager/events/cron/daily/marketing-newsletter-generate.js +24 -8
- package/src/manager/helpers/user.js +29 -0
- package/src/manager/index.js +111 -5
- package/src/manager/libraries/ai/index.js +21 -0
- package/src/manager/libraries/ai/providers/openai.js +75 -0
- package/src/manager/libraries/email/data/disposable-domains.json +20 -0
- package/src/manager/libraries/email/generators/lib/image-host.js +58 -6
- package/src/manager/libraries/email/generators/lib/markdown-renderer.js +285 -0
- package/src/manager/libraries/email/generators/lib/structure.js +19 -2
- package/src/manager/libraries/email/generators/lib/svg-illustrator.js +12 -3
- package/src/manager/libraries/email/generators/lib/templates/classic-schema.js +9 -13
- package/src/manager/libraries/email/generators/lib/templates/clean.js +1 -1
- package/src/manager/libraries/email/generators/lib/templates/editorial.js +1 -1
- package/src/manager/libraries/email/generators/lib/templates/field-report.js +10 -14
- package/src/manager/libraries/email/generators/newsletter.js +154 -7
- package/src/manager/libraries/email/providers/beehiiv.js +8 -1
- package/src/manager/libraries/payment/processors/stripe.js +12 -0
- package/src/manager/libraries/payment/processors/test.js +8 -1
- package/src/manager/routes/admin/infer-contact/post.js +3 -2
- package/src/manager/routes/admin/post/post.js +3 -3
- package/src/manager/routes/marketing/email-preferences/post.js +165 -37
- package/src/manager/routes/marketing/webhook/forward/post.js +168 -0
- package/src/manager/routes/marketing/webhook/post.js +180 -0
- package/src/manager/routes/marketing/webhook/processors/beehiiv.js +196 -0
- package/src/manager/routes/marketing/webhook/processors/sendgrid.js +171 -0
- package/src/manager/routes/payments/cancel/post.js +2 -2
- package/src/manager/routes/payments/cancel/processors/test.js +5 -2
- package/src/manager/routes/payments/intent/processors/test.js +7 -3
- package/src/manager/routes/payments/refund/processors/test.js +4 -1
- package/src/manager/routes/test/health/get.js +17 -0
- package/src/manager/routes/user/signup/post.js +65 -1
- package/src/manager/schemas/marketing/email-preferences/post.js +8 -4
- package/src/manager/schemas/marketing/webhook/forward/post.js +8 -0
- package/src/manager/schemas/marketing/webhook/post.js +7 -0
- package/src/manager/schemas/payments/cancel/post.js +5 -0
- package/src/manager/schemas/user/signup/post.js +5 -0
- package/src/test/run-tests.js +30 -0
- package/src/test/runner.js +72 -26
- package/src/test/test-accounts.js +94 -12
- package/src/test/utils/http-client.js +4 -3
- package/src/test/utils/test-mode-file.js +192 -0
- package/test/events/payments/journey-payments-cancel-endpoint.js +3 -12
- package/test/events/payments/journey-payments-cancel.js +4 -5
- package/test/events/payments/journey-payments-failure.js +0 -1
- package/test/events/payments/journey-payments-one-time-failure.js +6 -3
- package/test/events/payments/journey-payments-one-time.js +6 -3
- package/test/events/payments/journey-payments-plan-change.js +5 -5
- package/test/events/payments/journey-payments-refund-webhook.js +2 -3
- package/test/events/payments/journey-payments-suspend.js +4 -5
- package/test/events/payments/journey-payments-trial-cancel.js +3 -12
- package/test/events/payments/journey-payments-trial.js +2 -3
- package/test/events/payments/journey-payments-uid-resolution.js +2 -3
- package/test/functions/admin/database-read.js +0 -14
- package/test/functions/admin/database-write.js +0 -14
- package/test/functions/admin/firestore-query.js +0 -14
- package/test/functions/admin/firestore-read.js +0 -15
- package/test/functions/admin/firestore-write.js +0 -11
- package/test/functions/general/add-marketing-contact.js +16 -14
- package/test/helpers/email.js +1 -1
- package/test/helpers/infer-contact.js +3 -3
- package/test/helpers/user.js +241 -2
- package/test/helpers/webhook-forward.js +392 -0
- package/test/marketing/fixtures/clean.json +2 -3
- package/test/marketing/fixtures/editorial.json +2 -3
- package/test/marketing/fixtures/field-report.json +3 -4
- package/test/marketing/newsletter-generate.js +78 -54
- package/test/marketing/newsletter-templates.js +12 -33
- package/test/routes/admin/create-post.js +2 -2
- package/test/routes/admin/database.js +0 -13
- package/test/routes/admin/firestore-query.js +0 -13
- package/test/routes/admin/firestore.js +0 -14
- package/test/routes/admin/infer-contact.js +6 -3
- package/test/routes/admin/post.js +4 -2
- package/test/routes/marketing/contact.js +60 -26
- package/test/routes/marketing/email-preferences.js +145 -69
- package/test/routes/marketing/webhook-forward.js +54 -0
- package/test/routes/marketing/webhook.js +582 -0
- package/test/routes/payments/cancel.js +2 -7
- package/test/routes/payments/dispute-alert.js +0 -39
- package/test/routes/payments/refund.js +3 -1
- package/test/routes/payments/webhook.js +5 -26
- package/test/routes/test/usage.js +2 -2
- package/test/routes/user/signup.js +114 -0
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"permissions": {
|
|
3
|
+
"allow": [
|
|
4
|
+
"Bash(grep -B2 -A20 \"Generate a newsletter preview\" /tmp/bem-test-run-3.log | head -50)",
|
|
5
|
+
"Bash(sed 's/\\\\x1b\\\\[[0-9;]*m//g')",
|
|
6
|
+
"Read(//tmp/**)",
|
|
7
|
+
"Bash(TEST_EXTENDED_MODE=1 npx mgr test 2>&1 | sed 's/\\\\x1b\\\\[[0-9;]*m//g' > /tmp/bem-run-6.log; grep -E \"passing|failing|skipped\" /tmp/bem-run-6.log | tail -5)",
|
|
8
|
+
"Bash(awk -F'`' '{print $2}')",
|
|
9
|
+
"Bash(TEST_EXTENDED_MODE=1 npx mgr test 2>&1 | sed 's/\\\\x1b\\\\[[0-9;]*m//g' > /tmp/bem-cleanup-run2.log; grep -E \"\\(passing|failing|skipped\\)\" /tmp/bem-cleanup-run2.log | tail -5)"
|
|
10
|
+
]
|
|
11
|
+
}
|
|
12
|
+
}
|
package/CHANGELOG.md
CHANGED
|
@@ -14,6 +14,58 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
|
|
14
14
|
- `Fixed` for any bug fixes.
|
|
15
15
|
- `Security` in case of vulnerabilities.
|
|
16
16
|
|
|
17
|
+
# [5.2.0] - 2026-05-21
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
|
|
21
|
+
- **Marketing consent capture (Phase A-B).** Canonical `consent.{legal,marketing}` sub-tree on every user doc (`status` + `grantedAt` / `revokedAt` with timestamp/source/ip/text). Signup route (`src/manager/routes/user/signup/post.js`) builds the record from the client payload using server-side time + IP, defending against client-clock spoofing. `mailer.sync(uid)` is gated on `consent.marketing.status === 'granted'` — opted-out signups never enter SendGrid/Beehiiv marketing lists.
|
|
22
|
+
- **Email-preferences route (Phase D).** `POST /marketing/email-preferences` now supports authenticated opt-in/opt-out (writes user doc + hits both providers via `email.sync()`/`email.remove()`) in addition to the existing HMAC anonymous unsub flow. Anonymous unsub also writes the consent revoke on the user doc with the right `source`.
|
|
23
|
+
- **Cross-provider unsubscribe webhooks (Phase E).** New `POST /marketing/webhook?provider={sendgrid|beehiiv}&key=...` dispatcher with per-provider processor modules. SendGrid events (`unsubscribe`, `group_unsubscribe`, `spamreport`, `bounce`, `dropped`) and Beehiiv events (`subscription.unsubscribed`, `.deleted`, `.paused`) flip `consent.marketing.status` to `revoked`, attribute via `source`, and propagate to the OTHER provider. Idempotent via `marketing-webhooks/{eventId}` docs.
|
|
24
|
+
- **Parent BEM forwarder.** `POST /marketing/webhook/forward` lets a parent BEM (one with `config.parent === 'self'`) fan webhook events out to sibling brands sharing a SendGrid account or Beehiiv publication. New `Manager.getParentUrl()`, `Manager.getParentApiUrl()`, `Manager.isParent()` helpers — children store the parent's `brand.url` with NO `api.` subdomain and the helper inserts `api.` at call time.
|
|
25
|
+
- **Self-contained TEST_EXTENDED_MODE.** `src/test/runner.js` + `src/test/test-accounts.js` now do pre + post-run cleanup of SendGrid/Beehiiv contacts (the only third-party state we can't wipe at start). Pure Firestore/Auth state is still wiped only at start, per existing convention.
|
|
26
|
+
- **New docs.** `docs/consent.md` (consent system + webhook flows + migration template). `docs/testing.md` updated with the post-run-cleanup exception.
|
|
27
|
+
|
|
28
|
+
### Changed
|
|
29
|
+
|
|
30
|
+
- `src/test/runner.js`, `src/test/utils/http-client.js`, `src/cli/commands/test.js`, plus emulator/serve/watch CLIs: renamed `hostingUrl` → `apiUrl` to match the rest of the codebase. Touches most test files for the matching context-API rename.
|
|
31
|
+
- `src/manager/libraries/email/generators/newsletter.js`: uses `Manager.getParentApiUrl()` instead of reading `Manager.config.parent` directly so the `'self'` sentinel and the missing `api.` subdomain are both handled in one place.
|
|
32
|
+
- `src/manager/libraries/email/providers/beehiiv.js`: exposes `getPublicationId` so generators and tests can read it without reaching into the module.
|
|
33
|
+
- Disposable-domain blacklist refreshed (8 new domains) via `prepare-package`'s pre-hook.
|
|
34
|
+
|
|
35
|
+
### Fixed
|
|
36
|
+
|
|
37
|
+
- `mailer.sync()` and `mailer.add()` short-circuit when the target user's `consent.marketing.status === 'revoked'` so we never re-add an opted-out user.
|
|
38
|
+
- Payment processor + cancel route touchups picked up by the runner-API rename pass — no behavior change, just keeping signatures aligned.
|
|
39
|
+
|
|
40
|
+
# [5.1.4] - 2026-05-18
|
|
41
|
+
|
|
42
|
+
### Fixed
|
|
43
|
+
|
|
44
|
+
- **`POST /admin/post` response `path`** now returns the full `.md` file path (e.g. `src/_posts/2026/guest/2026-05-14-my-post.md`) instead of the parent directory. Consumers (e.g. the sponsorship system) were treating it as a file path — which it was named to be — and downstream deletes failed with `"sha" wasn't supplied` because GitHub got a directory listing. The parent directory is now exposed separately as `directory` for consumers that still want it. Updated `test/routes/admin/create-post.js` accordingly.
|
|
45
|
+
|
|
46
|
+
# [5.1.3] - 2026-05-18
|
|
47
|
+
|
|
48
|
+
### Added
|
|
49
|
+
|
|
50
|
+
- **Live `TEST_EXTENDED_MODE` sync between `npx mgr test` and the running emulator.** Test command writes an allowlisted env subset to `<projectRoot>/.temp/test-mode.json` pre-flight; emulator's function workers watch the file via `fs.watch` and mutate their own `process.env` in place — flag flips take effect within ~50ms with no env coordination across terminals. No more "restart the emulator with `TEST_EXTENDED_MODE=true`" dance. Health endpoint re-reads the file as a freshness guard. New helper `src/test/utils/test-mode-file.js` is the SSOT for the file format and allowlist.
|
|
51
|
+
- **Real BEM Manager in test contexts.** `run-tests.js` sets `BEM_TEST_RUNNER=1` before loading any BEM code; `Manager.init()` auto-detects this and skips Functions/server/Sentry wiring + `admin.initializeApp()` (which can't run outside a real Functions runtime). Result: tests receive `{ Manager, assistant }` in their context and can call `Manager.AI()`, `Manager.Email()`, `Manager.User()`, etc. exactly like production — no hand-rolled stubs.
|
|
52
|
+
- **Newsletter markdown + summary outputs.** `lib/markdown-renderer.js` walks the same `structure` JSON the HTML is rendered from (no AI cost) and emits `newsletter.md` — each section/dispatch is a standalone `## heading` block ready to paste into Beehiiv's editor one block at a time with ad blocks inserted between dispatches. A separate `summary.md` (2-3 sentence editorial recap) is written alongside.
|
|
53
|
+
- **`summary` and `tags` fields on the structure schema.** `summary` is ≤600 chars, used for the `summary.md` body and as a share snippet (distinct from preheader, which is an inbox hook). `tags` is 0-5 lowercase kebab-case topical tags, passed to Beehiiv's `content_tags` on draft creation.
|
|
54
|
+
- **GitHub asset host writes MD + summary alongside HTML.** `lib/image-host.js` accepts `markdown` + `summary` parameters and uploads `newsletter.md` / `summary.md` to `{brandId}/{campaignId}/` in the same atomic two-commit upload as PNGs + HTML. URLs surface on `assets.markdownUrl` / `assets.summaryUrl`.
|
|
55
|
+
- **Beehiiv fallback alert email.** When Beehiiv draft creation fails (e.g. free-plan `SEND_API_NOT_ENTERPRISE_PLAN`), the generator sends an internal alert via `sender: 'internal'` (alerts@{brandDomain}) to `brand.contact.email` containing the failure reason + subject/preheader/tags + direct links to HTML/MD/summary/folder. The newsletter is never stuck. Best-effort; failure to send is logged but never blocks the Firestore campaign-doc write.
|
|
56
|
+
- **Universal AI system-prompt injections.** `Manager.AI()` `normalizeOptions()` now prepends two rules (em-dash ban, confidentiality) to every system prompt — every caller picks them up automatically.
|
|
57
|
+
- **GPT-5 Codex family pricing** (`gpt-5.3-codex`, `gpt-5.2-codex`, `gpt-5.1-codex-max`, `gpt-5.1-codex`, `gpt-5.1-codex-mini`, `gpt-5-codex`, `codex-mini-latest`) added to the OpenAI provider's MODEL_TABLE.
|
|
58
|
+
- **`docs/common-mistakes.md`** and **`docs/key-files.md`** — extracted from CLAUDE.md to keep the architectural overview under 250 lines.
|
|
59
|
+
|
|
60
|
+
### Changed
|
|
61
|
+
|
|
62
|
+
- **SVG illustrator default flipped to `gpt-5.3-codex`.** Codex is the markup/code-specialized GPT-5 variant; SVG is structured markup, so it's the right fit. Anthropic remains supported as a fallback provider.
|
|
63
|
+
- **`structure` schema now requires `summary` and `tags`.** Existing generators pick these up automatically — the AI prompt was updated to instruct on both.
|
|
64
|
+
- **CTAs removed from generated section bodies.** The AI cannot author URLs reliably (no browse access, no real source URLs), so any link it produced was invented. Newsletters are self-contained reads; outbound links come exclusively from the template shell's sponsorship blocks (`marketing.beehiiv.content.sponsorships[]`). Test fixtures updated.
|
|
65
|
+
- **CLAUDE.md restructured** into the standard skeleton (Identity → Recommended skills → Quick Start → Architecture → CLI → File Conventions → Doc-update parity → Documentation index). Per-subsystem details extracted to `docs/`.
|
|
66
|
+
- **Consumer default CLAUDE.md** updated for the new `logs:read` / `logs:tail` / `firestore:*` / `auth:*` CLI commands.
|
|
67
|
+
- **Runner mode reporting.** The old `TEST_EXTENDED_MODE mismatch` warning is gone (made impossible by the live sync) — replaced with a "Mode: EXTENDED/normal" line sourced from the emulator's health-endpoint confirmation.
|
|
68
|
+
|
|
17
69
|
# [5.1.2] - 2026-05-14
|
|
18
70
|
|
|
19
71
|
### Changed
|
package/CLAUDE.md
CHANGED
|
@@ -122,6 +122,7 @@ Deep references live in `docs/`. **Whenever you make a behavioral change, update
|
|
|
122
122
|
- [docs/admin-post-route.md](docs/admin-post-route.md) — `POST/PUT /admin/post` blog creation via GitHub (image extraction + `@post/` rewriting)
|
|
123
123
|
- [docs/payment-system.md](docs/payment-system.md) — full payment pipeline: Intent → Webhook → On-Write → Transition; subscription model, statuses, `resolveSubscription()`, transition handlers, processor interface, product config, test processor
|
|
124
124
|
- [docs/marketing-campaigns.md](docs/marketing-campaigns.md) — campaign CRUD routes, recurring campaigns, generator pipeline (newsletter), template-owned schemas, asset hosting, seed campaigns
|
|
125
|
+
- [docs/consent.md](docs/consent.md) — marketing consent capture: canonical `consent.{legal,marketing}` user-doc shape, signup-form capture, account-page toggle, HMAC unsub link, SendGrid+Beehiiv webhook receivers, parent forwarder (`/marketing/webhook/forward`), migration script template
|
|
125
126
|
- [docs/mcp.md](docs/mcp.md) — Model Context Protocol server: 19 tools, stdio + HTTP transports, OAuth, Claude Chat/Code configuration
|
|
126
127
|
|
|
127
128
|
### Subsystems & Libraries
|
|
@@ -133,6 +134,6 @@ Deep references live in `docs/`. **Whenever you make a behavioral change, update
|
|
|
133
134
|
|
|
134
135
|
### Testing & CLI
|
|
135
136
|
|
|
136
|
-
- [docs/testing.md](docs/testing.md) — running, filtering, log files, test types (standalone/suite/group), context object, assertions, auth levels
|
|
137
|
+
- [docs/testing.md](docs/testing.md) — running, filtering, log files, test types (standalone/suite/group), context object, assertions, auth levels. **For Firestore/Auth/local state, cleanup runs at the START of every run, never at the end** — if you add a test that writes Firestore data, register the collection/namespace in the runner's pre-test wipe list, don't add a trailing cleanup step. **Exception:** third-party providers we can't wipe at start (e.g. SendGrid/Beehiiv contact lists) get a symmetric pre + post cleanup hook in the runner — see `cleanupMarketingProviders` and [docs/consent.md](docs/consent.md). Don't pattern-match this exception for new Firestore code.
|
|
137
138
|
- [docs/cli-firestore-auth.md](docs/cli-firestore-auth.md) — `npx mgr firestore:*` and `auth:*` commands, shared flags, examples
|
|
138
139
|
- [docs/cli-logs.md](docs/cli-logs.md) — `npx mgr logs:read` / `logs:tail` with full flag reference and built-in Cloud Function names
|
package/README.md
CHANGED
|
@@ -419,6 +419,21 @@ Built-in marketing system with multi-provider support (SendGrid + Beehiiv + FCM
|
|
|
419
419
|
|
|
420
420
|
Configure via `marketing` section in `backend-manager-config.json`. See CLAUDE.md for full documentation.
|
|
421
421
|
|
|
422
|
+
## Marketing Consent
|
|
423
|
+
|
|
424
|
+
GDPR/CASL-compliant consent capture and cross-provider unsubscribe sync.
|
|
425
|
+
|
|
426
|
+
- **Two-checkbox signup form** — separate legal (required) and marketing (optional) consent
|
|
427
|
+
- **Canonical user-doc shape** — `consent.{legal,marketing}.{status, grantedAt, revokedAt}` with full audit metadata (timestamp, source, IP, exact label text)
|
|
428
|
+
- **Server-authoritative timestamps** — client timestamps ignored, defending against clock manipulation
|
|
429
|
+
- **Account-page toggle** — `/account` notifications section lets logged-in users opt in/out, hits both SendGrid + Beehiiv
|
|
430
|
+
- **HMAC unsubscribe links** — email-footer one-click flow continues to work
|
|
431
|
+
- **Provider webhook receivers** — `POST /marketing/webhook?provider=sendgrid|beehiiv&key=X` catches unsubscribe / spam / bounce events from SendGrid and Beehiiv, writes the user doc + syncs to the OTHER provider
|
|
432
|
+
- **Parent forwarder** — single public webhook endpoint (`/marketing/webhook/forward`) on the parent BEM fans out to every brand's child BEM so each one updates its own Firestore
|
|
433
|
+
- **Guard against marketing sync without consent** — signup route and `email.add()` short-circuit when `consent.marketing.status !== 'granted'`
|
|
434
|
+
|
|
435
|
+
See [docs/consent.md](docs/consent.md) for the full architecture, source enum reference, migration script template, and provider configuration steps.
|
|
436
|
+
|
|
422
437
|
## Helper Classes
|
|
423
438
|
|
|
424
439
|
### Assistant
|
|
@@ -809,6 +824,21 @@ npx mgr test # Terminal 2 - runs tests
|
|
|
809
824
|
npx mgr test
|
|
810
825
|
```
|
|
811
826
|
|
|
827
|
+
### Extended Mode (real APIs)
|
|
828
|
+
|
|
829
|
+
Set `TEST_EXTENDED_MODE=true` on the **test command** to opt into real external API calls (SendGrid, Beehiiv, Stripe webhook handlers, marketing libraries). The flag flows automatically to the running emulator via `<projectRoot>/.temp/test-mode.json` — no need to set it on the emulator too:
|
|
830
|
+
|
|
831
|
+
```bash
|
|
832
|
+
# Terminal 1 — start once, no flag needed
|
|
833
|
+
npx mgr emulator
|
|
834
|
+
|
|
835
|
+
# Terminal 2 — toggle freely between runs
|
|
836
|
+
TEST_EXTENDED_MODE=true npx mgr test ... # extended mode
|
|
837
|
+
npx mgr test ... # normal mode (next run flips back)
|
|
838
|
+
```
|
|
839
|
+
|
|
840
|
+
See [docs/testing.md](docs/testing.md#extended-mode-test_extended_mode) for the full mechanism.
|
|
841
|
+
|
|
812
842
|
### Filtering Tests
|
|
813
843
|
|
|
814
844
|
```bash
|
package/docs/common-mistakes.md
CHANGED
|
@@ -9,3 +9,4 @@
|
|
|
9
9
|
7. **Use short-circuit returns** — Return early from error conditions
|
|
10
10
|
8. **Increment usage before update** — Call `usage.increment()` then `usage.update()`
|
|
11
11
|
9. **Add Firestore composite indexes for new compound queries** — Any new Firestore query using multiple `.where()` clauses or `.where()` + `.orderBy()` requires a composite index. Add it to `src/cli/commands/setup-tests/helpers/required-indexes.js` (the SSOT). Consumer projects pick these up via `npx mgr setup`, which syncs them into `firestore.indexes.json`. Without the index, the query will crash with `FAILED_PRECONDITION` in production.
|
|
12
|
+
10. **Don't put test data cleanup at the END of a test** — End-of-test cleanup doesn't fire when the previous run was killed mid-execution, so the next run inherits stale state. ALL test-data cleanup belongs in the runner's pre-test phase ([../src/test/test-accounts.js](../src/test/test-accounts.js) `deleteTestUsers()` + [../src/test/runner.js](../src/test/runner.js) `setupAccounts()`). When adding a test that writes data: register the Firestore collection in `testDataCollections` (mixed prod+test data) or `testOnlyCollections` (test-only), or use the `_test-` doc-id prefix so the id-keyed pass catches it. The only acceptable trailing cleanup is within-run state isolation (one test removes a doc so the NEXT test in the same run sees a clean slate) — that's not preparing the next run, it's intra-run housekeeping. See [testing.md](testing.md) "Test Data Cleanup".
|
package/docs/consent.md
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
# Marketing Consent System
|
|
2
|
+
|
|
3
|
+
Captures, stores, and synchronizes user consent for legal terms (ToS + Privacy) and marketing communications across SendGrid + Beehiiv. Designed for GDPR / CASL / CAN-SPAM compliance with full audit metadata.
|
|
4
|
+
|
|
5
|
+
This doc covers the **server-side** (BEM) part of the system. The matching frontend pieces live in [ultimate-jekyll-manager](https://github.com/itw-creative-works/ultimate-jekyll-manager) and [web-manager](https://github.com/itw-creative-works/web-manager).
|
|
6
|
+
|
|
7
|
+
## Why this exists
|
|
8
|
+
|
|
9
|
+
1. **Capture explicit, affirmative consent** at signup with separate checkboxes for legal terms (required) and marketing communications (optional). Store the exact label text the user agreed to.
|
|
10
|
+
2. **Let users withdraw consent at any time** via the account-page toggle or the email-footer unsubscribe link.
|
|
11
|
+
3. **Stay in sync with provider-side actions** — when a user clicks unsubscribe in a SendGrid or Beehiiv email, the user doc updates AND the OTHER provider is also notified.
|
|
12
|
+
4. **Never re-add an unsubscribed user** — `email.add()` and `email.sync()` short-circuit when `consent.marketing.status === 'revoked'`.
|
|
13
|
+
|
|
14
|
+
## Canonical user-doc shape
|
|
15
|
+
|
|
16
|
+
Every user doc has a `consent` object with two sub-trees:
|
|
17
|
+
|
|
18
|
+
```js
|
|
19
|
+
consent: {
|
|
20
|
+
legal: {
|
|
21
|
+
status: 'granted' | 'revoked',
|
|
22
|
+
grantedAt: { timestamp, timestampUNIX, source, ip, text },
|
|
23
|
+
},
|
|
24
|
+
marketing: {
|
|
25
|
+
status: 'granted' | 'revoked',
|
|
26
|
+
grantedAt: { timestamp, timestampUNIX, source, ip, text },
|
|
27
|
+
revokedAt: { timestamp, timestampUNIX, source, ip, text },
|
|
28
|
+
},
|
|
29
|
+
}
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
**Field semantics:**
|
|
33
|
+
|
|
34
|
+
- `status` — single-source-of-truth boolean state, expressed as a string enum so future states (`'pending'`, `'expired'`) don't break the schema.
|
|
35
|
+
- `grantedAt` / `revokedAt` — full audit metadata for the **most recent** transition of each kind. Both ALWAYS present on the doc; nulls live at the leaves (e.g. `grantedAt: { timestamp: null, ... }`), never at the object boundary. Matches BEM's `subscription.expires` / `payment.startDate` conventions.
|
|
36
|
+
- `legal` only has `grantedAt` (no `revokedAt`) because revoking legal consent = deleting the account.
|
|
37
|
+
- `text` records the **exact wording** the user agreed to. Critical for audit defense if a marketing label is challenged later.
|
|
38
|
+
|
|
39
|
+
**Object always present, nulls at leaves.** No special-casing required when reading — `account.consent.marketing.grantedAt.timestamp` is either an ISO string or `null`, never `undefined`.
|
|
40
|
+
|
|
41
|
+
### Source enum
|
|
42
|
+
|
|
43
|
+
| Source | Where it fires | Side |
|
|
44
|
+
|---|---|---|
|
|
45
|
+
| `'signup'` | Signup form checkbox toggled at account creation | both |
|
|
46
|
+
| `'account'` | `/account` notifications page toggle | both |
|
|
47
|
+
| `'admin'` | Manual admin override | both |
|
|
48
|
+
| `'imported'` | Legacy user migration backfill | both |
|
|
49
|
+
| `'sendgrid'` | SendGrid webhook event (`group_unsubscribe`, etc.) | revoke only |
|
|
50
|
+
| `'beehiiv'` | Beehiiv webhook event (`subscription.unsubscribed`, etc.) | revoke only |
|
|
51
|
+
|
|
52
|
+
## Capture points
|
|
53
|
+
|
|
54
|
+
There are four places where consent gets recorded or updated. All four converge on the same canonical shape.
|
|
55
|
+
|
|
56
|
+
### 1. Signup form (Phase B)
|
|
57
|
+
|
|
58
|
+
[src/manager/routes/user/signup/post.js](../src/manager/routes/user/signup/post.js) — the existing `/user/signup` route now accepts a `consent` settings field:
|
|
59
|
+
|
|
60
|
+
```js
|
|
61
|
+
// Client sends (lightweight transit shape):
|
|
62
|
+
{
|
|
63
|
+
consent: {
|
|
64
|
+
legal: { granted: true, text: 'I agree to ...' },
|
|
65
|
+
marketing: { granted: false, text: 'I agree to receive ...' },
|
|
66
|
+
},
|
|
67
|
+
}
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
`buildConsentRecord(assistant, settings.consent)` translates this into the canonical user-doc shape:
|
|
71
|
+
|
|
72
|
+
- `legal.granted: true` → `legal.status = 'granted'`, `grantedAt` populated with `source: 'signup'` + **server timestamp** + server-detected IP + exact label text.
|
|
73
|
+
- `marketing.granted: false` → `marketing.status = 'revoked'`, `grantedAt` all-null, `revokedAt` populated with `source: 'signup'`. (Records the explicit decline.)
|
|
74
|
+
- Missing `consent` block (legacy client) → both default to `'revoked'`.
|
|
75
|
+
|
|
76
|
+
**Server time is authoritative.** Client-supplied timestamps are ignored — defends against clock manipulation by malicious clients.
|
|
77
|
+
|
|
78
|
+
**Strict boolean check.** Only `granted === true` counts as granted. `'true'`, `1`, or other truthy values are rejected.
|
|
79
|
+
|
|
80
|
+
**Marketing sync gating.** After writing the user doc, the route checks `userRecord.consent.marketing.status === 'granted'` before calling `mailer.sync(uid)`. Declining the marketing checkbox means the user is created normally, gets transactional emails, but is NEVER added to SendGrid / Beehiiv marketing lists.
|
|
81
|
+
|
|
82
|
+
### 2. Account-page toggle (Phase D)
|
|
83
|
+
|
|
84
|
+
[src/manager/routes/marketing/email-preferences/post.js](../src/manager/routes/marketing/email-preferences/post.js) — authenticated mode.
|
|
85
|
+
|
|
86
|
+
```
|
|
87
|
+
POST /backend-manager/marketing/email-preferences
|
|
88
|
+
Body: { action: 'subscribe' | 'unsubscribe' }
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
- Requires authentication (uses the calling user's auth UID and email).
|
|
92
|
+
- Rate-limited per-user via `Manager.Usage().init(assistant)` (5/day).
|
|
93
|
+
- Writes `consent.marketing.{status, grantedAt|revokedAt}` to the user doc with `source: 'account'` + server time + server IP.
|
|
94
|
+
- Calls `mailer.sync(uid)` on subscribe, `mailer.remove(email)` on unsubscribe — hits both SendGrid + Beehiiv via the email library.
|
|
95
|
+
|
|
96
|
+
Note: `grantedAt.text` is `null` for account-page subscribes because the marketing label text is not currently passed from the frontend toggle (TODO if needed).
|
|
97
|
+
|
|
98
|
+
### 3. HMAC unsubscribe link (legacy, Phase D)
|
|
99
|
+
|
|
100
|
+
Same route, anonymous mode. The existing email-footer unsubscribe link flow:
|
|
101
|
+
|
|
102
|
+
```
|
|
103
|
+
POST /backend-manager/marketing/email-preferences
|
|
104
|
+
Body: { email, asmId, sig, action: 'subscribe' | 'unsubscribe' }
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
- `sig = HMAC-SHA256(email, UNSUBSCRIBE_HMAC_KEY)` — proves we generated the link.
|
|
108
|
+
- IP-rate-limited (5/day per IP).
|
|
109
|
+
- **Also writes the user doc** if the email maps to a user — `consent.marketing.{status, revokedAt}` with `source: 'sendgrid'` (since HMAC links only appear in SendGrid email footers).
|
|
110
|
+
- Backward-compatible — old in-flight email links continue to work.
|
|
111
|
+
|
|
112
|
+
### 4. Provider webhooks (Phase E)
|
|
113
|
+
|
|
114
|
+
[src/manager/routes/marketing/webhook/post.js](../src/manager/routes/marketing/webhook/post.js) — receives unsub / spam / bounce events from SendGrid and Beehiiv.
|
|
115
|
+
|
|
116
|
+
```
|
|
117
|
+
POST /backend-manager/marketing/webhook?provider=sendgrid&key=<BACKEND_MANAGER_WEBHOOK_KEY>
|
|
118
|
+
POST /backend-manager/marketing/webhook?provider=beehiiv&key=<BACKEND_MANAGER_WEBHOOK_KEY>
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
The dispatcher loads `processors/{provider}.js`, parses the event(s), and for each event:
|
|
122
|
+
|
|
123
|
+
1. Checks `isSupported(eventType)` — filters out non-revoke events like `delivered` / `open`.
|
|
124
|
+
2. Idempotency lookup in `marketing-webhooks/{eventId}` — skips if already processed.
|
|
125
|
+
3. Calls `handleEvent({ Manager, assistant, parsed })` on the processor.
|
|
126
|
+
4. Marks the idempotency doc `status: 'completed'` (or `'failed'` with error details).
|
|
127
|
+
|
|
128
|
+
Each processor's `handleEvent` does the same shape of work:
|
|
129
|
+
|
|
130
|
+
1. Look up the user by `auth.email` in THIS brand's Firestore. Silent skip if not found (the email may belong to a sibling brand — see "Parent forwarder" below).
|
|
131
|
+
2. Write `consent.marketing.status = 'revoked'` with the appropriate `source` ('sendgrid' or 'beehiiv'), preserving `grantedAt` as informational audit history.
|
|
132
|
+
3. Call `mailer.remove(email)` to sync the unsubscribe to the OTHER provider (best-effort, idempotent on 404).
|
|
133
|
+
|
|
134
|
+
**Supported event types:**
|
|
135
|
+
|
|
136
|
+
| Provider | Event types treated as revoke |
|
|
137
|
+
|---|---|
|
|
138
|
+
| SendGrid | `unsubscribe`, `group_unsubscribe`, `spamreport`, `bounce`, `dropped` |
|
|
139
|
+
| Beehiiv | `subscription.unsubscribed`, `subscription.deleted`, `subscription.paused` |
|
|
140
|
+
|
|
141
|
+
**Beehiiv publication filter.** Each Beehiiv event includes a `publication_id`. The processor compares this against the result of `beehiivProvider.getPublicationId()` (which reads `Manager.config.marketing.beehiiv.publicationId` or fuzzy-matches against the Beehiiv API by brand name). Mismatch → silent skip. This is how shared-publication events (e.g. devbeans shared by 6 brands) get routed correctly — each brand processes only events matching its own publication.
|
|
142
|
+
|
|
143
|
+
## Parent forwarder (Phase E)
|
|
144
|
+
|
|
145
|
+
[src/manager/routes/marketing/webhook/forward/post.js](../src/manager/routes/marketing/webhook/forward/post.js)
|
|
146
|
+
|
|
147
|
+
SendGrid and Beehiiv only let you configure a small number of webhook URLs (often one per account). With many brands sharing the same SendGrid account, we can't point the webhook at every brand's BEM directly. Instead:
|
|
148
|
+
|
|
149
|
+
```
|
|
150
|
+
SendGrid → POST https://api.itwcreativeworks.com/backend-manager/marketing/webhook/forward?provider=sendgrid&key=X
|
|
151
|
+
Beehiiv → POST https://api.itwcreativeworks.com/backend-manager/marketing/webhook/forward?provider=beehiiv&key=X
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
The **parent BEM** (the one whose `backend-manager-config.json` has `parent: 'self'`) exposes the forwarder route. Every other BEM has the route but it returns 404 (gated on `Manager.config.parent === 'self'`).
|
|
155
|
+
|
|
156
|
+
The parent forwarder:
|
|
157
|
+
|
|
158
|
+
1. Validates `?provider=X&key=Y` (same `BACKEND_MANAGER_WEBHOOK_KEY` env var — shared across all brands).
|
|
159
|
+
2. Reads the `brands` collection from the parent's own Firestore.
|
|
160
|
+
3. For each brand: derives the child API URL by inserting `api.` into the brand's URL (`https://somiibo.com` → `https://api.somiibo.com/backend-manager/marketing/webhook?provider=X&key=Y`).
|
|
161
|
+
4. POSTs the raw provider body to every child in parallel via `Promise.allSettled`.
|
|
162
|
+
5. Returns 200 even if some children fail — child idempotency makes provider retries safe.
|
|
163
|
+
|
|
164
|
+
### Why fan-out instead of central processing
|
|
165
|
+
|
|
166
|
+
Each brand has its own Firebase project, so its `users` collection is separate. The parent can't write to a child's Firestore. By having each child process the event against its own users, we get:
|
|
167
|
+
|
|
168
|
+
- **Correct per-brand updates** — only brands where the user actually has an account update their user docs.
|
|
169
|
+
- **Failure isolation** — one child being down doesn't block updates on the others.
|
|
170
|
+
- **Local idempotency** — each child tracks `marketing-webhooks/{eventId}` in its own Firestore.
|
|
171
|
+
- **No new schema** — no need for the parent to maintain a brand → publication map; each child filters on its own.
|
|
172
|
+
|
|
173
|
+
### Why self IS in the fan-out
|
|
174
|
+
|
|
175
|
+
The parent BEM has its own brand (e.g. `itw-creative-works`) with its own users. By fanning out via HTTP to itself like any other child, the parent's brand processes its users the same way as siblings — no special-case inline path.
|
|
176
|
+
|
|
177
|
+
### Shared-publication scenario (Beehiiv devbeans)
|
|
178
|
+
|
|
179
|
+
1. User on shared "devbeans" publication clicks unsubscribe.
|
|
180
|
+
2. Beehiiv posts event with `publication_id: pub_69c961a7...` to parent's `/marketing/webhook/forward`.
|
|
181
|
+
3. Parent fans out the raw event to all N brands.
|
|
182
|
+
4. The 6 brands sharing the devbeans publication: `getPublicationId()` matches, they each look up the user, only the brand(s) with the user write the doc and call `mailer.remove`.
|
|
183
|
+
5. The brands with dedicated publications: `getPublicationId()` mismatch, silent skip.
|
|
184
|
+
|
|
185
|
+
## Email library short-circuit
|
|
186
|
+
|
|
187
|
+
[src/manager/libraries/email/marketing/index.js](../src/manager/libraries/email/marketing/index.js)
|
|
188
|
+
|
|
189
|
+
`email.add()` and `email.sync()` check the user's `consent.marketing.status` before contacting providers. A user marked `'revoked'` is never re-added. This is the safety net against accidental re-subscription via batch syncs or campaign sends.
|
|
190
|
+
|
|
191
|
+
## Configuration
|
|
192
|
+
|
|
193
|
+
### Env vars (per brand)
|
|
194
|
+
|
|
195
|
+
```bash
|
|
196
|
+
# All brands
|
|
197
|
+
BACKEND_MANAGER_WEBHOOK_KEY="<shared-across-all-brands>"
|
|
198
|
+
|
|
199
|
+
# Existing (unchanged)
|
|
200
|
+
UNSUBSCRIBE_HMAC_KEY="<existing-value>"
|
|
201
|
+
SENDGRID_API_KEY="<account-wide>"
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
The webhook key is shared because it has to be the same value the parent forwards to each child. Rotate by updating every brand's env in lockstep.
|
|
205
|
+
|
|
206
|
+
### Provider dashboard setup
|
|
207
|
+
|
|
208
|
+
**SendGrid Event Webhook** (Settings → Mail Settings → Event Webhook):
|
|
209
|
+
```
|
|
210
|
+
URL: https://api.itwcreativeworks.com/backend-manager/marketing/webhook/forward?provider=sendgrid&key=<BACKEND_MANAGER_WEBHOOK_KEY>
|
|
211
|
+
Events: Group Unsubscribe, Unsubscribe, Spam Report, Bounce, Dropped
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
**Beehiiv Webhooks** (per-publication setup):
|
|
215
|
+
- Dedicated publications: point at the single brand's parent URL.
|
|
216
|
+
- Shared "devbeans" publication: point at the parent URL — fan-out handles the routing.
|
|
217
|
+
|
|
218
|
+
```
|
|
219
|
+
URL: https://api.itwcreativeworks.com/backend-manager/marketing/webhook/forward?provider=beehiiv&key=<BACKEND_MANAGER_WEBHOOK_KEY>
|
|
220
|
+
Events: subscription.unsubscribed, subscription.deleted, subscription.paused
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
### Parent vs child config
|
|
224
|
+
|
|
225
|
+
Parent's `backend-manager-config.json`:
|
|
226
|
+
```js
|
|
227
|
+
{
|
|
228
|
+
parent: 'self',
|
|
229
|
+
brand: { id: 'itw-creative-works', url: 'https://itwcreativeworks.com', ... },
|
|
230
|
+
...
|
|
231
|
+
}
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
Every other brand:
|
|
235
|
+
```js
|
|
236
|
+
{
|
|
237
|
+
parent: 'https://itwcreativeworks.com', // NO `api.` subdomain — inserted at call time
|
|
238
|
+
brand: { id: 'somiibo', url: 'https://somiibo.com', ... },
|
|
239
|
+
...
|
|
240
|
+
}
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
**Convention:** `parent` stores the parent's brand URL (matching the format of `brand.url`), NOT the API URL. The `api.` subdomain is inserted at call time by `Manager.getParentApiUrl()`. This keeps stored config in one consistent format and lets the deployment convention (`api.` subdomain) live in one place.
|
|
244
|
+
|
|
245
|
+
**Three helpers** on the Manager instance for working with this:
|
|
246
|
+
|
|
247
|
+
- `Manager.getParentUrl()` — returns the parent's brand URL. Resolves `'self'` to `Manager.config.brand.url`.
|
|
248
|
+
- `Manager.getParentApiUrl()` — returns the parent's API URL (`https://api.{host}`). **Always live** — does NOT redirect to localhost in dev mode, because you can't run two Firebase emulators simultaneously. The parent's API is always the production URL regardless of which environment THIS brand is in.
|
|
249
|
+
- `Manager.isParent()` — boolean, true when `config.parent === 'self'`.
|
|
250
|
+
|
|
251
|
+
Only the BEM where `Manager.isParent()` returns true exposes `/marketing/webhook/forward`. Everywhere else, the route is invisible (404).
|
|
252
|
+
|
|
253
|
+
## Legacy user migration
|
|
254
|
+
|
|
255
|
+
Existing users created BEFORE the consent system has no `consent` field. They need a one-time backfill. The shape per the agreed strategy:
|
|
256
|
+
|
|
257
|
+
```js
|
|
258
|
+
// For every legacy user doc
|
|
259
|
+
{
|
|
260
|
+
consent: {
|
|
261
|
+
legal: {
|
|
262
|
+
status: 'granted', // implicit from active account
|
|
263
|
+
grantedAt: {
|
|
264
|
+
timestamp: userDoc.metadata?.created?.timestamp || null,
|
|
265
|
+
timestampUNIX: userDoc.metadata?.created?.timestampUNIX || null,
|
|
266
|
+
source: 'imported',
|
|
267
|
+
ip: userDoc.activity?.geolocation?.ip || null,
|
|
268
|
+
text: null, // don't fabricate label text
|
|
269
|
+
},
|
|
270
|
+
},
|
|
271
|
+
marketing: {
|
|
272
|
+
status: 'revoked', // no opt-in on record
|
|
273
|
+
grantedAt: { timestamp: null, timestampUNIX: null, source: null, ip: null, text: null },
|
|
274
|
+
revokedAt: {
|
|
275
|
+
timestamp: userDoc.metadata?.created?.timestamp || null,
|
|
276
|
+
timestampUNIX: userDoc.metadata?.created?.timestampUNIX || null,
|
|
277
|
+
source: 'imported',
|
|
278
|
+
ip: userDoc.activity?.geolocation?.ip || null,
|
|
279
|
+
text: null,
|
|
280
|
+
},
|
|
281
|
+
},
|
|
282
|
+
},
|
|
283
|
+
}
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
**Idempotency guard:** skip docs where `consent.legal.grantedAt.source` already has a non-null value (those went through the new signup flow or a prior migration run).
|
|
287
|
+
|
|
288
|
+
Run the migration BEFORE enabling the frontend's page-load consent guard (see UJM `ENFORCE_CONSENT_GUARD` flag in `src/assets/js/core/auth.js`). Otherwise legacy users without `consent.legal.status === 'granted'` get signed out on every page load.
|
|
289
|
+
|
|
290
|
+
After the migration: optionally run a re-opt-in drip campaign to legally recover marketing consent for the users you bulk-revoked.
|
|
291
|
+
|
|
292
|
+
## Test coverage
|
|
293
|
+
|
|
294
|
+
**BEM tests:**
|
|
295
|
+
|
|
296
|
+
- [test/helpers/user.js](../test/helpers/user.js) — 31 tests covering the canonical schema, defaults, granted/revoked states, round-tripping
|
|
297
|
+
- [test/routes/user/signup.js](../test/routes/user/signup.js) — 3 tests for signup-time consent capture (granted both, marketing declined, missing payload)
|
|
298
|
+
- [test/routes/marketing/email-preferences.js](../test/routes/marketing/email-preferences.js) — 14 tests for the email-preferences route (anonymous HMAC + authenticated)
|
|
299
|
+
- [test/routes/marketing/webhook.js](../test/routes/marketing/webhook.js) — 15+ tests covering SendGrid + Beehiiv processors against the emulator
|
|
300
|
+
- [test/routes/marketing/webhook-forward.js](../test/routes/marketing/webhook-forward.js) — verifies the forwarder route returns 404 on non-parent BEMs
|
|
301
|
+
- [test/helpers/webhook-forward.js](../test/helpers/webhook-forward.js) — 12 unit-style tests with mocked admin + fetch, covering fan-out, URL derivation, failure isolation, self-inclusion, edge cases
|
|
302
|
+
|
|
303
|
+
**Total: 75+ tests across the consent system.**
|
|
304
|
+
|
|
305
|
+
Run with `npx mgr test` (full suite) or `npx mgr test routes/marketing/webhook` (just the webhook tests).
|
|
306
|
+
|
|
307
|
+
### Provider-side cleanup (extended mode only)
|
|
308
|
+
|
|
309
|
+
Normally BEM test runs are self-contained because all data lives in the local emulator. Marketing tests are the exception — when `TEST_EXTENDED_MODE=true` is set, the routes make real API calls to SendGrid + Beehiiv and leave real contacts behind.
|
|
310
|
+
|
|
311
|
+
To keep this self-contained, the runner calls [`cleanupMarketingProviders`](../src/test/test-accounts.js) **twice** per run:
|
|
312
|
+
|
|
313
|
+
1. **Pre-run** — scrubs leftovers from any previous run that crashed mid-suite.
|
|
314
|
+
2. **Post-run** — scrubs everything this run created. Best-effort (wrapped in try/catch — cleanup failures don't change the test result).
|
|
315
|
+
|
|
316
|
+
Both fire ONLY when `TEST_EXTENDED_MODE=true`. In normal mode (the default), no provider calls happen and no cleanup is needed.
|
|
317
|
+
|
|
318
|
+
This is the one exception to the "Firestore cleanup runs at start only" rule documented in [docs/testing.md](testing.md) — we can't wipe SendGrid/Beehiiv state in our own Firestore wipe, so we have to ask the providers to do it for us, twice for defense in depth.
|
|
319
|
+
|
|
320
|
+
## Frontend pieces (cross-references)
|
|
321
|
+
|
|
322
|
+
- **UJM signup form** — [signup.html](https://github.com/itw-creative-works/ultimate-jekyll-manager/blob/main/src/defaults/dist/_layouts/themes/classy/frontend/pages/auth/signup.html) (two consent checkboxes, inline error UX)
|
|
323
|
+
- **UJM auth library** — [libs/auth.js](https://github.com/itw-creative-works/ultimate-jekyll-manager/blob/main/src/assets/js/libs/auth.js) (`captureSignupConsent`, `validateConsent`, `reverseAccidentalSignup` for the Google-on-signin quirk)
|
|
324
|
+
- **UJM core auth listener** — [core/auth.js](https://github.com/itw-creative-works/ultimate-jekyll-manager/blob/main/src/assets/js/core/auth.js) (`ENFORCE_CONSENT_GUARD` flag, page-load silent-signout for orphan accounts)
|
|
325
|
+
- **UJM account page** — [account/index.html](https://github.com/itw-creative-works/ultimate-jekyll-manager/blob/main/src/defaults/dist/_layouts/themes/classy/frontend/pages/account/index.html) + [sections/notifications.js](https://github.com/itw-creative-works/ultimate-jekyll-manager/blob/main/src/assets/js/pages/account/sections/notifications.js)
|
|
326
|
+
- **Web Manager DEFAULT_ACCOUNT** — [modules/auth.js](https://github.com/itw-creative-works/web-manager/blob/main/src/modules/auth.js) (consent fields with `'revoked'` defaults so legacy reads don't crash)
|
|
327
|
+
|
|
328
|
+
## Future work
|
|
329
|
+
|
|
330
|
+
- **Country-aware default checkbox state** — pre-check both checkboxes in jurisdictions where it's legally permitted (e.g. US under CAN-SPAM). Out of scope for the initial rollout; TODO comment in signup.html.
|
|
331
|
+
- **Re-consent flow for material label changes** — if the marketing label text changes meaningfully, prompt existing users to re-consent (versioning via the stored `text` field).
|
|
332
|
+
- **Audit-log sub-collection** — currently only the most-recent transition is kept. If legal needs full history, add `users/{uid}/consent-history/{transition-id}`.
|
|
333
|
+
- **ECDSA / HMAC signature verification** on webhooks — SendGrid supports it, Beehiiv requires HMAC. Currently bearer-token only (`?key=`). Future hardening.
|
|
@@ -152,7 +152,17 @@ AI provider defaults live in code (openai for structure, anthropic for SVG — e
|
|
|
152
152
|
|
|
153
153
|
## Asset hosting (production cron flow)
|
|
154
154
|
|
|
155
|
-
The daily cron uploads PNGs + the rendered `newsletter.html` to the public `itw-creative-works/newsletter-assets` repo as two atomic Git Trees commits per issue
|
|
155
|
+
The daily cron uploads per-section PNGs + the rendered `newsletter.html` + `newsletter.md` + `summary.md` to the public `itw-creative-works/newsletter-assets` repo as two atomic Git Trees commits per issue (PNGs first so URLs exist for embedding, then HTML/MD/summary in a second commit). Folder layout:
|
|
156
|
+
|
|
157
|
+
```
|
|
158
|
+
{brandId}/{campaignId}/
|
|
159
|
+
section-N.png — per-section illustration (embedded in HTML)
|
|
160
|
+
newsletter.html — final rendered email-safe HTML
|
|
161
|
+
newsletter.md — programmatic markdown view (per-section ## blocks, ready for Beehiiv paste)
|
|
162
|
+
summary.md — short editorial recap (2-3 sentences)
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
`newsletter.md` is built programmatically from the same `structure` JSON the HTML is rendered from (no AI cost) by `lib/markdown-renderer.js`. Each section/dispatch becomes a standalone `## heading` block — drop it into the Beehiiv editor one block at a time and insert ad blocks between dispatches.
|
|
156
166
|
|
|
157
167
|
The `campaignId` is the same Firestore doc ID the cron uses for the generated `marketing-campaigns/{newId}` doc, reserved up front so the GitHub URLs and the Firestore doc always match.
|
|
158
168
|
|
|
@@ -164,14 +174,40 @@ marketing-campaigns/{newId}: {
|
|
|
164
174
|
assets: {
|
|
165
175
|
campaignId, // same as the doc id
|
|
166
176
|
folderUrl, // https://github.com/itw-creative-works/newsletter-assets/tree/main/{brandId}/{campaignId}
|
|
167
|
-
htmlUrl, // https://raw.githubusercontent.com/.../newsletter.html — paste this into Beehiiv
|
|
168
|
-
|
|
177
|
+
htmlUrl, // https://raw.githubusercontent.com/.../newsletter.html — paste this into Beehiiv as one block
|
|
178
|
+
markdownUrl, // https://raw.githubusercontent.com/.../newsletter.md — per-section blocks (ads between)
|
|
179
|
+
summaryUrl, // https://raw.githubusercontent.com/.../summary.md — share-snippet recap
|
|
180
|
+
imageUrls: [...], // raw.githubusercontent.com URLs already embedded in contentHtml
|
|
181
|
+
beehiivPostId, // ID of the draft post created on Beehiiv (null if disabled/failed)
|
|
182
|
+
tags: [...] // AI-generated content tags (also passed to Beehiiv `content_tags`)
|
|
169
183
|
},
|
|
170
184
|
meta: { tokens, cost, durations, source scores },
|
|
171
185
|
...
|
|
172
186
|
}
|
|
173
187
|
```
|
|
174
188
|
|
|
189
|
+
**`structure` schema (universals)** — every newsletter the generator produces satisfies this regardless of template:
|
|
190
|
+
|
|
191
|
+
| Field | Purpose |
|
|
192
|
+
|---|---|
|
|
193
|
+
| `subject` | Email subject (≤80 chars) |
|
|
194
|
+
| `preheader` | Inbox preview text (≤120 chars) |
|
|
195
|
+
| `summary` | 2-3 sentence editorial recap (≤600 chars) — written to `summary.md` and used as share snippet. Distinct from preheader (which is an inbox hook). |
|
|
196
|
+
| `tags` | 0-5 topical tags (lowercase kebab-case) — passed to Beehiiv `content_tags` |
|
|
197
|
+
| `signoff` | Two-line closing |
|
|
198
|
+
| `citations` | 0-10 `{note, source}` pairs rendered as footnotes |
|
|
199
|
+
|
|
200
|
+
Templates add their own fields on top (e.g. classic adds `intro` + `sections`; field-report adds `tldr` + `dateline` + `dispatches`).
|
|
201
|
+
|
|
202
|
+
**No CTAs in generated content.** The schema intentionally does NOT include section-level CTAs / outbound links. The AI cannot author URLs reliably — it has no browse access to your site and no real source URLs to reference, so any URL it produces is invented. Newsletters are self-contained reads; outbound links come from sponsorship blocks rendered by the template shell (driven by `marketing.beehiiv.content.sponsorships[]`), not from generated section bodies.
|
|
203
|
+
|
|
204
|
+
**Beehiiv failure → fallback alert email.** When Beehiiv draft creation fails (e.g. `SEND_API_NOT_ENTERPRISE_PLAN` on the free plan), the generator sends an internal alert email via `sender: 'internal'` (resolves to `alerts@{brandDomain}`) to `brand.contact.email` with:
|
|
205
|
+
- The failure reason
|
|
206
|
+
- Subject, preheader, tags
|
|
207
|
+
- Direct links to the rendered HTML, per-section markdown, summary, and the full GitHub folder
|
|
208
|
+
|
|
209
|
+
This means the newsletter is never "stuck" — even with Beehiiv disabled or failing, you get an actionable email pointing to ready-to-paste assets. The alert is best-effort; failure to send is logged but does not block the Firestore campaign-doc write.
|
|
210
|
+
|
|
175
211
|
Requires `GITHUB_TOKEN` env var (org-scoped, write access to `newsletter-assets`). Without it, the cron's HTML/image upload calls throw and the run aborts.
|
|
176
212
|
|
|
177
213
|
## Iteration test asset story
|
|
@@ -232,7 +268,8 @@ marketing: {
|
|
|
232
268
|
| Newsletter copy (AI) | `src/manager/libraries/email/generators/lib/structure.js` |
|
|
233
269
|
| Newsletter SVG (AI) | `src/manager/libraries/email/generators/lib/svg-illustrator.js` |
|
|
234
270
|
| Newsletter MJML → HTML | `src/manager/libraries/email/generators/lib/mjml-template.js` |
|
|
235
|
-
| Newsletter asset host (GitHub upload — PNGs + newsletter.html) | `src/manager/libraries/email/generators/lib/image-host.js` |
|
|
271
|
+
| Newsletter asset host (GitHub upload — PNGs + newsletter.html + newsletter.md + summary.md) | `src/manager/libraries/email/generators/lib/image-host.js` |
|
|
272
|
+
| Newsletter markdown renderer (programmatic, no AI) | `src/manager/libraries/email/generators/lib/markdown-renderer.js` |
|
|
236
273
|
| Unified AI library | `src/manager/libraries/ai/index.js` (OpenAI + Anthropic via `Manager.AI(assistant).request({ provider, ... })`) |
|
|
237
274
|
| Notification library | `src/manager/libraries/notification.js` |
|
|
238
275
|
| SendGrid provider | `src/manager/libraries/email/providers/sendgrid.js` |
|