backend-manager 5.2.1 → 5.2.3
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 +24 -0
- package/README.md +6 -5
- package/docs/testing.md +2 -2
- package/package.json +1 -1
- package/src/cli/commands/base-command.js +67 -0
- package/src/cli/commands/emulator.js +6 -2
- package/src/cli/commands/logs.js +4 -6
- package/src/cli/commands/serve.js +6 -2
- package/src/cli/commands/setup.js +10 -4
- package/src/cli/commands/test.js +3 -4
- package/src/cli/commands/watch.js +4 -4
- package/src/manager/libraries/email/data/blocked-local-patterns.js +10 -5
- package/src/manager/libraries/email/marketing/index.js +15 -7
- package/src/manager/libraries/email/providers/beehiiv.js +86 -70
- package/src/manager/libraries/email/providers/sendgrid.js +99 -66
- package/templates/backend-manager-config.json +2 -1
- package/templates/firestore.rules +2 -1
- package/test/rules/user.js +23 -1
package/CHANGELOG.md
CHANGED
|
@@ -14,6 +14,30 @@ 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.3] - 2026-05-22
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
|
|
21
|
+
- **`marketing.sendgrid.listId` in `templates/backend-manager-config.json`.** Empty-string placeholder for OMEGA's `sendgrid/ensure/list.js` to populate at brand-onboarding time. Mirrors the existing `marketing.beehiiv.publicationId` convention.
|
|
22
|
+
- **`_test.*` local-part block** in `src/manager/libraries/email/data/blocked-local-patterns.js`. Test-suite accounts (`_test.<scenario>@somiibo.com`) are now blocked from reaching SendGrid + Beehiiv. The carved-out exception is `_test.allow_*` — used for live-provider integration tests that intentionally need to round-trip a real contact.
|
|
23
|
+
|
|
24
|
+
### Changed
|
|
25
|
+
|
|
26
|
+
- **`Marketing.add()` and `Marketing.sync()` now use the full `validate()` pipeline** instead of just `isCorporate()`. Single SSOT for "is this a valid marketing email" — runs format → disposable → corporate → localPart in one call. Stricter behavior: disposable-domain emails (mailinator etc.) and junk local-parts (`noreply`, `test*`, `_test.*`) are now blocked from marketing lists. They were previously waved through because the gate only checked corporate domains.
|
|
27
|
+
- **SendGrid list-ID lookup is now config-only.** `src/manager/libraries/email/providers/sendgrid.js#getListId()` reads `Manager.config.marketing.sendgrid.listId` and returns null if missing — no more runtime API call, no more fuzzy-match-by-brand-name. Old fuzzy logic kept commented out as `getListIdByFuzzyMatch()` backstop. **Brands must run OMEGA's sendgrid service to populate `listId` before this version sees their list assignments work** (without it, contacts land in SendGrid's global "All Contacts" pool, not the brand list).
|
|
28
|
+
- **Beehiiv publication-ID lookup is now config-only.** `src/manager/libraries/email/providers/beehiiv.js#getPublicationId()` reads `Manager.config.marketing.beehiiv.publicationId` and returns null if missing — same shape as SendGrid above. Old fuzzy logic kept commented out as backstop. Beehiiv side already preferred config when set, but now there's no API fallback.
|
|
29
|
+
- **`marketing.beehiiv.publicationId` is now an always-present empty string in the config template** (was a commented-out hint). Matches the SendGrid `listId` shape and means `Manager.config.marketing.beehiiv.publicationId` always returns `""` (never `undefined`) for legacy brands.
|
|
30
|
+
- **SendGrid API timeouts bumped from 10s → 60s** via a new top-level `SENDGRID_TIMEOUT_MS` constant in `sendgrid.js`. All 9 fetch sites updated. Catches the intermittent SendGrid backend hiccups that were dropping signups silently with "Request timed out".
|
|
31
|
+
|
|
32
|
+
# [5.2.2] - 2026-05-21
|
|
33
|
+
|
|
34
|
+
### Added
|
|
35
|
+
|
|
36
|
+
- **`consent` is now a protected user field.** `templates/firestore.rules` includes `consent` in `isWritingProtectedUserField()` so a logged-in user cannot retroactively forge their own consent record from the client — only the signup route + webhook processors can mutate it server-side. New rule test in `test/rules/user.js`.
|
|
37
|
+
- **`BaseCommand.getLogsPath(name)` / `getTempPath(name)`** in `src/cli/commands/base-command.js`. Two explicit helpers so the folder convention is the SSOT and easy to change later. `getLogsPath()` writes human-readable logs (`serve.log`, `emulator.log`, `test.log`, `logs.log`) to `<projectDir>/functions/` alongside firebase-tools' own `*-debug.log` files. `getTempPath()` writes transient internal-only stuff (`*.log.reset` sentinels, `bem-reload-trigger.js`, `test-mode.json`) to `<projectDir>/.temp/`.
|
|
38
|
+
- **`BaseCommand.sweepStaleLogs()`** wipes BEM's own `.log` files in `functions/` and `.reset` sentinels in `.temp/` on every emulator/serve boot and on `npx mgr setup`. Deliberately does NOT touch firebase-tools' debug logs (`firestore-debug.log`, `database-debug.log`, etc.) so users can grep them after a crash.
|
|
39
|
+
- **`npx mgr setup` cleanup step.** `cleanupGeneratedArtifacts()` now removes the watch trigger file (existing behavior) plus calls `sweepStaleLogs()` to clean up old BEM-owned logs/sentinels from previous runs.
|
|
40
|
+
|
|
17
41
|
# [5.2.1] - 2026-05-21
|
|
18
42
|
|
|
19
43
|
### Added
|
package/README.md
CHANGED
|
@@ -850,12 +850,13 @@ npx mgr test user/ admin/ # Multiple paths
|
|
|
850
850
|
|
|
851
851
|
### Log Files
|
|
852
852
|
|
|
853
|
-
BEM CLI commands automatically save output to log files in the project directory:
|
|
854
|
-
- **`
|
|
855
|
-
- **`
|
|
856
|
-
- **`
|
|
853
|
+
BEM CLI commands automatically save output to log files in the project's `functions/` directory (alongside firebase-tools' own `*-debug.log` files so everything is grep-able from one place):
|
|
854
|
+
- **`functions/serve.log`** — Output from `npx mgr serve`
|
|
855
|
+
- **`functions/emulator.log`** — Full emulator + Cloud Functions output (`npx mgr emulator`)
|
|
856
|
+
- **`functions/test.log`** — Test runner output (`npx mgr test`, when running against an existing emulator)
|
|
857
|
+
- **`functions/logs.log`** — Cloud Function logs (`npx mgr logs:read` or `npx mgr logs:tail`)
|
|
857
858
|
|
|
858
|
-
Logs are overwritten on each run
|
|
859
|
+
Logs are overwritten on each run and gitignored via `*.log`. Use them to debug failing tests or review function output. Transient internal artifacts (reset sentinels, watch trigger, `test-mode.json`) live separately in `<projectDir>/.temp/`.
|
|
859
860
|
|
|
860
861
|
### Test Locations
|
|
861
862
|
|
package/docs/testing.md
CHANGED
|
@@ -94,7 +94,7 @@ npx mgr test ... # ← this still flips it back
|
|
|
94
94
|
|
|
95
95
|
## Log Files
|
|
96
96
|
|
|
97
|
-
BEM CLI commands automatically save all output to log files in
|
|
97
|
+
BEM CLI commands automatically save all output to log files in `<projectDir>/functions/` while still streaming to the console — co-located with firebase-tools' own `*-debug.log` files so everything can be grepped from one directory:
|
|
98
98
|
- **`functions/serve.log`** — Output from `npx mgr serve` (Firebase serve)
|
|
99
99
|
- **`functions/emulator.log`** — Full emulator output (Firebase emulator + Cloud Functions logs)
|
|
100
100
|
- **`functions/test.log`** — Test runner output (when running against an existing emulator)
|
|
@@ -102,7 +102,7 @@ BEM CLI commands automatically save all output to log files in `functions/` whil
|
|
|
102
102
|
|
|
103
103
|
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`.
|
|
104
104
|
|
|
105
|
-
These files are overwritten on each run and are gitignored (`*.log`)
|
|
105
|
+
These files are overwritten on each run and are gitignored via `*.log`. Reset sentinels (`*.log.reset`), the watch trigger file, and `test-mode.json` live separately in `<projectDir>/.temp/` because they're transient internal signals with no debugging value.
|
|
106
106
|
|
|
107
107
|
## Filtering Tests
|
|
108
108
|
|
package/package.json
CHANGED
|
@@ -16,6 +16,73 @@ class BaseCommand {
|
|
|
16
16
|
throw new Error('Execute method must be implemented');
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
/**
|
|
20
|
+
* Resolve a path inside the consumer project's `.temp/` directory. Used for
|
|
21
|
+
* TRULY internal artifacts that have no debugging value: reset sentinels
|
|
22
|
+
* (*.log.reset), the watch command's reload trigger, and `test-mode.json`.
|
|
23
|
+
*
|
|
24
|
+
* For human-readable log files, use `getLogsPath()` instead — those live in
|
|
25
|
+
* `functions/` next to firebase-tools' own *-debug.log files so all log
|
|
26
|
+
* output can be grepped from one directory.
|
|
27
|
+
*
|
|
28
|
+
* Ensures the directory exists.
|
|
29
|
+
* @param {string} [filename] - File name to append (omit to get the dir path).
|
|
30
|
+
* @returns {string} Absolute path.
|
|
31
|
+
*/
|
|
32
|
+
getTempPath(filename) {
|
|
33
|
+
const projectDir = this.main.firebaseProjectPath;
|
|
34
|
+
const tempDir = path.join(projectDir, '.temp');
|
|
35
|
+
jetpack.dir(tempDir);
|
|
36
|
+
return filename ? path.join(tempDir, filename) : tempDir;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Resolve a path for a human-readable log file. BEM-owned logs (serve.log,
|
|
41
|
+
* emulator.log, test.log, logs.log) live in `functions/` alongside
|
|
42
|
+
* firebase-tools' own *-debug.log files so all log output is grep-able from
|
|
43
|
+
* one place. Reset sentinels and other internal-only artifacts use
|
|
44
|
+
* `getTempPath()` instead.
|
|
45
|
+
*
|
|
46
|
+
* @param {string} [filename] - File name to append (omit to get the dir path).
|
|
47
|
+
* @returns {string} Absolute path.
|
|
48
|
+
*/
|
|
49
|
+
getLogsPath(filename) {
|
|
50
|
+
const projectDir = this.main.firebaseProjectPath;
|
|
51
|
+
const logsDir = path.join(projectDir, 'functions');
|
|
52
|
+
return filename ? path.join(logsDir, filename) : logsDir;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Sweep stale BEM-owned logs out of `functions/`. Catches `.log` files
|
|
57
|
+
* from previous runs so each emulator/serve/test boot starts with a clean
|
|
58
|
+
* slate. Also catches stale `.reset` sentinels in `.temp/` that a crashed
|
|
59
|
+
* process may have left behind.
|
|
60
|
+
*
|
|
61
|
+
* Firebase-tools writes its own debug logs (firestore-debug.log,
|
|
62
|
+
* database-debug.log, pubsub-debug.log, firebase-debug.log, ui-debug.log) to
|
|
63
|
+
* cwd and we can't redirect them — we deliberately do NOT touch those, so
|
|
64
|
+
* users can grep them after a crash.
|
|
65
|
+
*/
|
|
66
|
+
sweepStaleLogs() {
|
|
67
|
+
const logFiles = [
|
|
68
|
+
'serve.log',
|
|
69
|
+
'emulator.log',
|
|
70
|
+
'test.log',
|
|
71
|
+
'logs.log',
|
|
72
|
+
];
|
|
73
|
+
const resetSentinels = [
|
|
74
|
+
'serve.log.reset',
|
|
75
|
+
'emulator.log.reset',
|
|
76
|
+
];
|
|
77
|
+
|
|
78
|
+
for (const name of logFiles) {
|
|
79
|
+
try { jetpack.remove(this.getLogsPath(name)); } catch (e) { /* best-effort */ }
|
|
80
|
+
}
|
|
81
|
+
for (const name of resetSentinels) {
|
|
82
|
+
try { jetpack.remove(this.getTempPath(name)); } catch (e) { /* best-effort */ }
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
19
86
|
log(...args) {
|
|
20
87
|
console.log(...args);
|
|
21
88
|
}
|
|
@@ -73,6 +73,10 @@ class EmulatorCommand extends BaseCommand {
|
|
|
73
73
|
throw new Error('Port conflicts could not be resolved');
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
+
// Wipe stale firebase-tools debug logs + any leftover BEM logs from older
|
|
77
|
+
// versions. Keeps the project tree clean across runs.
|
|
78
|
+
this.sweepStaleLogs();
|
|
79
|
+
|
|
76
80
|
// BEM_TESTING=true is passed so Functions skip external API calls (emails, SendGrid)
|
|
77
81
|
// hosting is included so localhost:5002 rewrites work (e.g., /backend-manager -> bm_api)
|
|
78
82
|
// pubsub is included so scheduled functions (bm_cronDaily) can be triggered in tests
|
|
@@ -87,8 +91,8 @@ class EmulatorCommand extends BaseCommand {
|
|
|
87
91
|
// by touching emulator.log.reset — the watcher below detects it, closes the
|
|
88
92
|
// current stream, reopens with flags: 'w' (truncating cleanly from our process'
|
|
89
93
|
// perspective, no sparse-file issue), and deletes the sentinel.
|
|
90
|
-
const logPath =
|
|
91
|
-
const resetSentinelPath =
|
|
94
|
+
const logPath = this.getLogsPath('emulator.log');
|
|
95
|
+
const resetSentinelPath = this.getTempPath('emulator.log.reset');
|
|
92
96
|
const stripAnsi = (str) => str.replace(/\x1B\[[0-9;]*[a-zA-Z]/g, '');
|
|
93
97
|
|
|
94
98
|
let currentStream = fs.createWriteStream(logPath, { flags: 'w' });
|
package/src/cli/commands/logs.js
CHANGED
|
@@ -72,9 +72,8 @@ class LogsCommand extends BaseCommand {
|
|
|
72
72
|
`--order=${order}`,
|
|
73
73
|
].filter(Boolean).join(' ');
|
|
74
74
|
|
|
75
|
-
// Set up log file in the project directory
|
|
76
|
-
const
|
|
77
|
-
const logPath = path.join(projectDir, 'functions', 'logs.log');
|
|
75
|
+
// Set up log file in the project's functions/ directory
|
|
76
|
+
const logPath = this.getLogsPath('logs.log');
|
|
78
77
|
|
|
79
78
|
this.log(chalk.gray(` Filter: ${filter || '(none)'}`));
|
|
80
79
|
this.log(chalk.gray(` Limit: ${limit}`));
|
|
@@ -126,9 +125,8 @@ class LogsCommand extends BaseCommand {
|
|
|
126
125
|
const seenIds = new Set();
|
|
127
126
|
let stopped = false;
|
|
128
127
|
|
|
129
|
-
// Set up log file in the project directory
|
|
130
|
-
const
|
|
131
|
-
const logPath = path.join(projectDir, 'functions', 'logs.log');
|
|
128
|
+
// Set up log file in the project's functions/ directory
|
|
129
|
+
const logPath = this.getLogsPath('logs.log');
|
|
132
130
|
const logStream = fs.createWriteStream(logPath, { flags: 'w' });
|
|
133
131
|
|
|
134
132
|
const filter = this.buildFilter(argv, { excludeTimestamp: true });
|
|
@@ -18,6 +18,10 @@ class ServeCommand extends BaseCommand {
|
|
|
18
18
|
throw new Error('Port conflicts could not be resolved');
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
// Wipe stale firebase-tools debug logs + any leftover BEM logs from older
|
|
22
|
+
// versions. Keeps the project tree clean across runs.
|
|
23
|
+
this.sweepStaleLogs();
|
|
24
|
+
|
|
21
25
|
// Start BEM watcher in background
|
|
22
26
|
const watcher = new WatchCommand(self);
|
|
23
27
|
watcher.startBackground();
|
|
@@ -40,8 +44,8 @@ class ServeCommand extends BaseCommand {
|
|
|
40
44
|
// lines on the first cycle (subsequent cycles route those elsewhere), so
|
|
41
45
|
// we'd end up with a near-empty log. Rolling at the START of the cycle
|
|
42
46
|
// lets us capture whatever firebase-tools does emit, complete-or-not.
|
|
43
|
-
const logPath =
|
|
44
|
-
const resetSentinelPath =
|
|
47
|
+
const logPath = this.getLogsPath('serve.log');
|
|
48
|
+
const resetSentinelPath = this.getTempPath('serve.log.reset');
|
|
45
49
|
// Match any node version: "Using node@22 from host.", "Using node@20 from host.", etc.
|
|
46
50
|
const RELOAD_MARKER = /Using node@\d+ from host\./;
|
|
47
51
|
const stripAnsi = (str) => str.replace(/\x1B\[[0-9;]*[a-zA-Z]/g, '');
|
|
@@ -104,8 +104,8 @@ class SetupCommand extends BaseCommand {
|
|
|
104
104
|
throw new Error('Missing <engines.node> in package.json');
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
-
// Clean up leftover trigger files from
|
|
108
|
-
this.
|
|
107
|
+
// Clean up leftover trigger files + stale log files from older BEM versions
|
|
108
|
+
this.cleanupGeneratedArtifacts();
|
|
109
109
|
|
|
110
110
|
// Copy / merge defaults into consumer project root (matches EM/BXM/UJM pattern).
|
|
111
111
|
// Runs BEFORE tests so any test that inspects scaffolded files sees the merged state.
|
|
@@ -219,13 +219,19 @@ class SetupCommand extends BaseCommand {
|
|
|
219
219
|
}
|
|
220
220
|
}
|
|
221
221
|
|
|
222
|
-
|
|
222
|
+
cleanupGeneratedArtifacts() {
|
|
223
223
|
const self = this.main;
|
|
224
|
-
const triggerFile = `${self.firebaseProjectPath}/functions/bem-reload-trigger.js`;
|
|
225
224
|
|
|
225
|
+
// Remove the BEM reload-trigger file (transient artifact from `npx mgr watch`)
|
|
226
|
+
const triggerFile = `${self.firebaseProjectPath}/functions/bem-reload-trigger.js`;
|
|
226
227
|
if (jetpack.exists(triggerFile)) {
|
|
227
228
|
jetpack.remove(triggerFile);
|
|
228
229
|
}
|
|
230
|
+
|
|
231
|
+
// Sweep stale firebase-tools debug logs + leftover BEM logs from older
|
|
232
|
+
// versions (pre-5.2.2 they lived in functions/; now in .temp/). Shared
|
|
233
|
+
// implementation in base-command.js so emulator/serve boot also runs it.
|
|
234
|
+
this.sweepStaleLogs();
|
|
229
235
|
}
|
|
230
236
|
|
|
231
237
|
async runTests() {
|
package/src/cli/commands/test.js
CHANGED
|
@@ -199,8 +199,7 @@ class TestCommand extends BaseCommand {
|
|
|
199
199
|
* log just won't be reset for this run.
|
|
200
200
|
*/
|
|
201
201
|
async requestEmulatorLogReset(projectDir) {
|
|
202
|
-
const
|
|
203
|
-
const sentinelPath = `${logPath}.reset`;
|
|
202
|
+
const sentinelPath = this.getTempPath('emulator.log.reset');
|
|
204
203
|
|
|
205
204
|
try {
|
|
206
205
|
fs.writeFileSync(sentinelPath, '');
|
|
@@ -242,8 +241,8 @@ class TestCommand extends BaseCommand {
|
|
|
242
241
|
this.log(chalk.gray(` Auth: 127.0.0.1:${emulatorPorts.auth}`));
|
|
243
242
|
this.log(chalk.gray(` UI: http://127.0.0.1:${emulatorPorts.ui}`));
|
|
244
243
|
|
|
245
|
-
// Set up log file in the project directory
|
|
246
|
-
const logPath =
|
|
244
|
+
// Set up log file in the project's functions/ directory (alongside firebase-tools logs)
|
|
245
|
+
const logPath = this.getLogsPath('test.log');
|
|
247
246
|
const logStream = fs.createWriteStream(logPath, { flags: 'w' });
|
|
248
247
|
const stripAnsi = (str) => str.replace(/\x1B\[[0-9;]*[a-zA-Z]/g, '');
|
|
249
248
|
|
|
@@ -60,8 +60,8 @@ class WatchCommand extends BaseCommand {
|
|
|
60
60
|
// command isn't watching, the file is harmless and gets cleaned up by the next
|
|
61
61
|
// boot's stale-sentinel sweep.
|
|
62
62
|
const triggerFile = config.triggerFile;
|
|
63
|
-
const serveLogResetPath =
|
|
64
|
-
const emulatorLogResetPath =
|
|
63
|
+
const serveLogResetPath = this.getTempPath('serve.log.reset');
|
|
64
|
+
const emulatorLogResetPath = this.getTempPath('emulator.log.reset');
|
|
65
65
|
const nodemon = spawn(nodemonPath, [
|
|
66
66
|
'--on-change-only',
|
|
67
67
|
'--delay', '1',
|
|
@@ -104,8 +104,8 @@ class WatchCommand extends BaseCommand {
|
|
|
104
104
|
// Use nodemon to watch the BEM src directory and touch the trigger file on changes.
|
|
105
105
|
// Also drop <log>.reset sentinels so any sibling serve/emulator command rolls its
|
|
106
106
|
// log file on each reload (mirrors the test runner's log-roll pattern).
|
|
107
|
-
const serveLogResetPath =
|
|
108
|
-
const emulatorLogResetPath =
|
|
107
|
+
const serveLogResetPath = this.getTempPath('serve.log.reset');
|
|
108
|
+
const emulatorLogResetPath = this.getTempPath('emulator.log.reset');
|
|
109
109
|
const nodemon = spawn(nodemonPath, [
|
|
110
110
|
'--watch', config.bemSrcDir,
|
|
111
111
|
'--ext', 'js,json',
|
|
@@ -3,9 +3,14 @@
|
|
|
3
3
|
* Kept as JS (not JSON) so patterns stay as native RegExp literals.
|
|
4
4
|
*/
|
|
5
5
|
module.exports = [
|
|
6
|
-
/^\d+$/,
|
|
7
|
-
/^(.)\1{2,}$/,
|
|
8
|
-
/^[a-z]{1,2}\d+$/,
|
|
9
|
-
/^test/,
|
|
10
|
-
/^example/,
|
|
6
|
+
/^\d+$/, // All numeric: 123456
|
|
7
|
+
/^(.)\1{2,}$/, // Repeating single char: aaaa, xxxx
|
|
8
|
+
/^[a-z]{1,2}\d+$/, // Single letter + numbers: a123, x999
|
|
9
|
+
/^test/, // Starts with test: test, testuser, test123, test.user
|
|
10
|
+
/^example/, // Starts with example: example, exampleuser, example.user
|
|
11
|
+
// Test-suite accounts use `_test.<scenario>@...` so they don't collide with
|
|
12
|
+
// real signups. Block them from reaching SendGrid/Beehiiv so live lists stay
|
|
13
|
+
// clean. `_test.allow_*` is the carved-out exception for live-provider
|
|
14
|
+
// integration tests that intentionally need to round-trip a real contact.
|
|
15
|
+
/^_test\.(?!allow_)/,
|
|
11
16
|
];
|
|
@@ -29,7 +29,7 @@ const md = new MarkdownIt({ html: true, breaks: true, linkify: true });
|
|
|
29
29
|
|
|
30
30
|
const { TEMPLATES, GROUPS, SENDERS } = require('../constants.js');
|
|
31
31
|
const { tagLinks } = require('../utm.js');
|
|
32
|
-
const {
|
|
32
|
+
const { validate } = require('../validation.js');
|
|
33
33
|
const sendgridProvider = require('../providers/sendgrid.js');
|
|
34
34
|
const beehiivProvider = require('../providers/beehiiv.js');
|
|
35
35
|
|
|
@@ -73,9 +73,13 @@ Marketing.prototype.add = async function (options) {
|
|
|
73
73
|
return {};
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
76
|
+
// SSOT for what "valid marketing email" means — format, disposable, corporate,
|
|
77
|
+
// localPart junk (incl. _test.* test-suite accounts). Single validate() call
|
|
78
|
+
// catches all of these before we hit SendGrid/Beehiiv.
|
|
79
|
+
const validation = await validate(email);
|
|
80
|
+
if (!validation.valid) {
|
|
81
|
+
assistant.warn(`Marketing.add(): Validation failed, skipping: ${email}`, validation.checks);
|
|
82
|
+
return { blocked: 'validation', email, checks: validation.checks };
|
|
79
83
|
}
|
|
80
84
|
|
|
81
85
|
if (assistant.isTesting() && !process.env.TEST_EXTENDED_MODE) {
|
|
@@ -157,9 +161,13 @@ Marketing.prototype.sync = async function (userDocOrUid) {
|
|
|
157
161
|
return {};
|
|
158
162
|
}
|
|
159
163
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
164
|
+
// SSOT for what "valid marketing email" means — format, disposable, corporate,
|
|
165
|
+
// localPart junk (incl. _test.* test-suite accounts). Single validate() call
|
|
166
|
+
// catches all of these before we hit SendGrid/Beehiiv.
|
|
167
|
+
const validation = await validate(email);
|
|
168
|
+
if (!validation.valid) {
|
|
169
|
+
assistant.warn(`Marketing.sync(): Validation failed, skipping: ${email}`, validation.checks);
|
|
170
|
+
return { blocked: 'validation', email, checks: validation.checks };
|
|
163
171
|
}
|
|
164
172
|
|
|
165
173
|
if (assistant.isTesting() && !process.env.TEST_EXTENDED_MODE) {
|
|
@@ -131,80 +131,96 @@ async function removeSubscriber(email, publicationId) {
|
|
|
131
131
|
}
|
|
132
132
|
|
|
133
133
|
/**
|
|
134
|
-
* Get
|
|
134
|
+
* Get this brand's Beehiiv publication ID.
|
|
135
135
|
*
|
|
136
|
-
*
|
|
137
|
-
*
|
|
136
|
+
* Reads `Manager.config.marketing.beehiiv.publicationId` — populated by
|
|
137
|
+
* OMEGA's `beehiiv/ensure/publication.js` at brand-onboarding time. No
|
|
138
|
+
* runtime API call, no fuzzy-match fragility.
|
|
139
|
+
*
|
|
140
|
+
* If the brand hasn't been onboarded yet (publicationId missing/empty), logs
|
|
141
|
+
* a warning and returns null — the marketing sync will skip Beehiiv for this
|
|
142
|
+
* brand. Fix: run OMEGA's beehiiv service to populate.
|
|
143
|
+
*
|
|
144
|
+
* @returns {string|null} Publication ID or null if not configured
|
|
138
145
|
*/
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
async function getPublicationId() {
|
|
142
|
-
if (_publicationIdCache) {
|
|
143
|
-
return _publicationIdCache;
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
// Use publicationId from config if set (skips API call)
|
|
147
|
-
const configPubId = Manager.config?.marketing?.beehiiv?.publicationId;
|
|
148
|
-
|
|
149
|
-
if (configPubId) {
|
|
150
|
-
_publicationIdCache = configPubId;
|
|
151
|
-
return configPubId;
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
// Fuzzy-match by brand name (guard against uninitialized Manager singleton —
|
|
155
|
-
// happens in test stubs that build their own Manager without init()).
|
|
156
|
-
const brandName = Manager.config?.brand?.name;
|
|
146
|
+
function getPublicationId() {
|
|
147
|
+
const publicationId = Manager.config?.marketing?.beehiiv?.publicationId;
|
|
157
148
|
|
|
158
|
-
if (!
|
|
159
|
-
console.
|
|
149
|
+
if (!publicationId) {
|
|
150
|
+
console.warn(
|
|
151
|
+
'Beehiiv: marketing.beehiiv.publicationId is not set in config. '
|
|
152
|
+
+ 'Subscriber will NOT be added to a publication. '
|
|
153
|
+
+ 'Run OMEGA to populate.',
|
|
154
|
+
);
|
|
160
155
|
return null;
|
|
161
156
|
}
|
|
162
157
|
|
|
163
|
-
|
|
164
|
-
const allPublications = [];
|
|
165
|
-
let page = 1;
|
|
166
|
-
const limit = 100;
|
|
167
|
-
|
|
168
|
-
try {
|
|
169
|
-
while (true) {
|
|
170
|
-
const data = await fetch(`${BASE_URL}/publications?limit=${limit}&page=${page}`, {
|
|
171
|
-
response: 'json',
|
|
172
|
-
headers: headers(),
|
|
173
|
-
timeout: 10000,
|
|
174
|
-
});
|
|
175
|
-
|
|
176
|
-
if (!data.data || data.data.length === 0) {
|
|
177
|
-
break;
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
const matchedPub = data.data.find(pub =>
|
|
181
|
-
pub.name.toLowerCase() === brandNameLower
|
|
182
|
-
|| pub.name.toLowerCase().includes(brandNameLower)
|
|
183
|
-
|| brandNameLower.includes(pub.name.toLowerCase())
|
|
184
|
-
);
|
|
185
|
-
|
|
186
|
-
if (matchedPub) {
|
|
187
|
-
_publicationIdCache = matchedPub.id;
|
|
188
|
-
return matchedPub.id;
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
allPublications.push(...data.data);
|
|
192
|
-
|
|
193
|
-
if (data.data.length < limit) {
|
|
194
|
-
break;
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
page++;
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
console.error(`Beehiiv: No publication matched brand "${brandName}". Available: ${allPublications.map(p => p.name).join(', ')}`);
|
|
201
|
-
} catch (e) {
|
|
202
|
-
console.error('Beehiiv publication lookup error:', e);
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
return null;
|
|
158
|
+
return publicationId;
|
|
206
159
|
}
|
|
207
160
|
|
|
161
|
+
// LEGACY: Fuzzy-match-by-brand-name fallback. Kept commented out as a backstop
|
|
162
|
+
// in case the config-based approach has an edge case we haven't seen yet.
|
|
163
|
+
// Delete once we've verified the config-based approach works across all brands.
|
|
164
|
+
//
|
|
165
|
+
// let _publicationIdCache = null;
|
|
166
|
+
//
|
|
167
|
+
// async function getPublicationIdByFuzzyMatch() {
|
|
168
|
+
// if (_publicationIdCache) {
|
|
169
|
+
// return _publicationIdCache;
|
|
170
|
+
// }
|
|
171
|
+
//
|
|
172
|
+
// const brandName = Manager.config?.brand?.name;
|
|
173
|
+
//
|
|
174
|
+
// if (!brandName) {
|
|
175
|
+
// console.error('Beehiiv: Brand name is required to find publication');
|
|
176
|
+
// return null;
|
|
177
|
+
// }
|
|
178
|
+
//
|
|
179
|
+
// const brandNameLower = brandName.toLowerCase();
|
|
180
|
+
// const allPublications = [];
|
|
181
|
+
// let page = 1;
|
|
182
|
+
// const limit = 100;
|
|
183
|
+
//
|
|
184
|
+
// try {
|
|
185
|
+
// while (true) {
|
|
186
|
+
// const data = await fetch(`${BASE_URL}/publications?limit=${limit}&page=${page}`, {
|
|
187
|
+
// response: 'json',
|
|
188
|
+
// headers: headers(),
|
|
189
|
+
// timeout: 10000,
|
|
190
|
+
// });
|
|
191
|
+
//
|
|
192
|
+
// if (!data.data || data.data.length === 0) {
|
|
193
|
+
// break;
|
|
194
|
+
// }
|
|
195
|
+
//
|
|
196
|
+
// const matchedPub = data.data.find(pub =>
|
|
197
|
+
// pub.name.toLowerCase() === brandNameLower
|
|
198
|
+
// || pub.name.toLowerCase().includes(brandNameLower)
|
|
199
|
+
// || brandNameLower.includes(pub.name.toLowerCase())
|
|
200
|
+
// );
|
|
201
|
+
//
|
|
202
|
+
// if (matchedPub) {
|
|
203
|
+
// _publicationIdCache = matchedPub.id;
|
|
204
|
+
// return matchedPub.id;
|
|
205
|
+
// }
|
|
206
|
+
//
|
|
207
|
+
// allPublications.push(...data.data);
|
|
208
|
+
//
|
|
209
|
+
// if (data.data.length < limit) {
|
|
210
|
+
// break;
|
|
211
|
+
// }
|
|
212
|
+
//
|
|
213
|
+
// page++;
|
|
214
|
+
// }
|
|
215
|
+
//
|
|
216
|
+
// console.error(`Beehiiv: No publication matched brand "${brandName}". Available: ${allPublications.map(p => p.name).join(', ')}`);
|
|
217
|
+
// } catch (e) {
|
|
218
|
+
// console.error('Beehiiv publication lookup error:', e);
|
|
219
|
+
// }
|
|
220
|
+
//
|
|
221
|
+
// return null;
|
|
222
|
+
// }
|
|
223
|
+
|
|
208
224
|
/**
|
|
209
225
|
* Add a contact to Beehiiv — resolves publication, adds subscriber with optional custom fields.
|
|
210
226
|
*
|
|
@@ -217,7 +233,7 @@ async function getPublicationId() {
|
|
|
217
233
|
* @returns {{ success: boolean, id?: string, error?: string }}
|
|
218
234
|
*/
|
|
219
235
|
async function addContact({ email, firstName, lastName, company, source, customFields }) {
|
|
220
|
-
const publicationId =
|
|
236
|
+
const publicationId = getPublicationId();
|
|
221
237
|
|
|
222
238
|
if (!publicationId) {
|
|
223
239
|
return { success: false, error: 'Publication not found' };
|
|
@@ -245,7 +261,7 @@ async function addContact({ email, firstName, lastName, company, source, customF
|
|
|
245
261
|
* @returns {{ success: boolean, deleted?: boolean, skipped?: boolean, error?: string }}
|
|
246
262
|
*/
|
|
247
263
|
async function removeContact(email) {
|
|
248
|
-
const publicationId =
|
|
264
|
+
const publicationId = getPublicationId();
|
|
249
265
|
|
|
250
266
|
if (!publicationId) {
|
|
251
267
|
return { success: false, error: 'Publication not found' };
|
|
@@ -290,7 +306,7 @@ async function resolveSegmentIds() {
|
|
|
290
306
|
return _segmentIdCache;
|
|
291
307
|
}
|
|
292
308
|
|
|
293
|
-
const publicationId =
|
|
309
|
+
const publicationId = getPublicationId();
|
|
294
310
|
|
|
295
311
|
if (!publicationId) {
|
|
296
312
|
return {};
|
|
@@ -337,7 +353,7 @@ async function resolveSegmentIds() {
|
|
|
337
353
|
* @returns {{ success: boolean, id?: string, scheduled?: boolean, error?: string }}
|
|
338
354
|
*/
|
|
339
355
|
async function createPost(options) {
|
|
340
|
-
const publicationId = options.publicationId ||
|
|
356
|
+
const publicationId = options.publicationId || getPublicationId();
|
|
341
357
|
|
|
342
358
|
if (!publicationId) {
|
|
343
359
|
return { success: false, error: 'Publication not found' };
|
|
@@ -9,6 +9,12 @@ const { resolveFieldValues } = require('../constants.js');
|
|
|
9
9
|
|
|
10
10
|
const BASE_URL = 'https://api.sendgrid.com/v3';
|
|
11
11
|
|
|
12
|
+
// SendGrid's API is normally fast (<2s) but spikes past 10s during their
|
|
13
|
+
// hiccups, dropping signups silently. 60s is generous but harmless — the
|
|
14
|
+
// metadata calls (resolveFieldIds, getListId) are cached for the process
|
|
15
|
+
// lifetime so a slow first call costs nothing in steady state.
|
|
16
|
+
const SENDGRID_TIMEOUT_MS = 60000;
|
|
17
|
+
|
|
12
18
|
// --- Internal helpers ---
|
|
13
19
|
|
|
14
20
|
function headers() {
|
|
@@ -35,7 +41,7 @@ async function resolveFieldIds() {
|
|
|
35
41
|
const data = await fetch(`${BASE_URL}/marketing/field_definitions`, {
|
|
36
42
|
response: 'json',
|
|
37
43
|
headers: headers(),
|
|
38
|
-
timeout:
|
|
44
|
+
timeout: SENDGRID_TIMEOUT_MS,
|
|
39
45
|
});
|
|
40
46
|
|
|
41
47
|
_fieldIdCache = {};
|
|
@@ -70,7 +76,7 @@ async function resolveSegmentIds() {
|
|
|
70
76
|
const data = await fetch(`${BASE_URL}/marketing/segments/2.0`, {
|
|
71
77
|
response: 'json',
|
|
72
78
|
headers: headers(),
|
|
73
|
-
timeout:
|
|
79
|
+
timeout: SENDGRID_TIMEOUT_MS,
|
|
74
80
|
});
|
|
75
81
|
|
|
76
82
|
_segmentIdCache = {};
|
|
@@ -137,7 +143,7 @@ async function removeContact(email) {
|
|
|
137
143
|
method: 'post',
|
|
138
144
|
response: 'json',
|
|
139
145
|
headers: headers(),
|
|
140
|
-
timeout:
|
|
146
|
+
timeout: SENDGRID_TIMEOUT_MS,
|
|
141
147
|
body: { emails: [email] },
|
|
142
148
|
});
|
|
143
149
|
|
|
@@ -152,7 +158,7 @@ async function removeContact(email) {
|
|
|
152
158
|
method: 'delete',
|
|
153
159
|
response: 'json',
|
|
154
160
|
headers: headers(),
|
|
155
|
-
timeout:
|
|
161
|
+
timeout: SENDGRID_TIMEOUT_MS,
|
|
156
162
|
});
|
|
157
163
|
|
|
158
164
|
if (deleteData.job_id) {
|
|
@@ -167,69 +173,96 @@ async function removeContact(email) {
|
|
|
167
173
|
}
|
|
168
174
|
|
|
169
175
|
/**
|
|
170
|
-
* Get
|
|
176
|
+
* Get this brand's SendGrid Marketing list ID.
|
|
177
|
+
*
|
|
178
|
+
* Reads `Manager.config.marketing.sendgrid.listId` — populated by OMEGA's
|
|
179
|
+
* `sendgrid/ensure/list.js` at brand-onboarding time, same as how Beehiiv's
|
|
180
|
+
* `publicationId` works. No runtime API call, no fuzzy-match fragility.
|
|
171
181
|
*
|
|
172
|
-
*
|
|
173
|
-
*
|
|
182
|
+
* If the brand hasn't been onboarded yet (listId missing/empty), logs a
|
|
183
|
+
* warning and returns null — the marketing sync will still succeed, but the
|
|
184
|
+
* contact lands in SendGrid's global pool instead of the brand's list. Fix:
|
|
185
|
+
* run OMEGA's sendgrid service to populate the config.
|
|
186
|
+
*
|
|
187
|
+
* @returns {string|null} List ID or null if not configured
|
|
174
188
|
*/
|
|
175
|
-
|
|
176
|
-
const
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
const data = await fetch(url, {
|
|
186
|
-
response: 'json',
|
|
187
|
-
headers: headers(),
|
|
188
|
-
timeout: 10000,
|
|
189
|
-
});
|
|
190
|
-
|
|
191
|
-
if (!data.result || data.result.length === 0) {
|
|
192
|
-
break;
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
const matchedList = data.result.find(list =>
|
|
196
|
-
list.name.toLowerCase() === brandNameLower
|
|
197
|
-
|| list.name.toLowerCase().includes(brandNameLower)
|
|
198
|
-
|| brandNameLower.includes(list.name.toLowerCase())
|
|
199
|
-
);
|
|
200
|
-
|
|
201
|
-
if (matchedList) {
|
|
202
|
-
return matchedList.id;
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
allLists.push(...data.result);
|
|
206
|
-
|
|
207
|
-
if (!data._metadata?.next) {
|
|
208
|
-
break;
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
const nextUrl = new URL(data._metadata.next);
|
|
212
|
-
pageToken = nextUrl.searchParams.get('page_token');
|
|
213
|
-
|
|
214
|
-
if (!pageToken) {
|
|
215
|
-
break;
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
if (allLists.length === 1) {
|
|
220
|
-
return allLists[0].id;
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
if (allLists.length > 0) {
|
|
224
|
-
console.error(`SendGrid: No list matched brand "${brandName}". Available: ${allLists.map(l => l.name).join(', ')}`);
|
|
225
|
-
}
|
|
226
|
-
} catch (e) {
|
|
227
|
-
console.error('SendGrid list lookup error:', e);
|
|
189
|
+
function getListId() {
|
|
190
|
+
const listId = Manager.config.marketing?.sendgrid?.listId;
|
|
191
|
+
|
|
192
|
+
if (!listId) {
|
|
193
|
+
console.warn(
|
|
194
|
+
'SendGrid: marketing.sendgrid.listId is not set in config. '
|
|
195
|
+
+ 'Contact will be added to All Contacts only, not the brand list. '
|
|
196
|
+
+ 'Run OMEGA to populate.',
|
|
197
|
+
);
|
|
198
|
+
return null;
|
|
228
199
|
}
|
|
229
200
|
|
|
230
|
-
return
|
|
201
|
+
return listId;
|
|
231
202
|
}
|
|
232
203
|
|
|
204
|
+
// LEGACY: Fuzzy-match-by-brand-name fallback. Kept commented out as a backstop
|
|
205
|
+
// in case the config-based approach has an edge case we haven't seen yet.
|
|
206
|
+
// Delete once we've verified the config-based approach works across all brands.
|
|
207
|
+
//
|
|
208
|
+
// async function getListIdByFuzzyMatch() {
|
|
209
|
+
// const brandName = Manager.config.brand?.name;
|
|
210
|
+
// const brandNameLower = (brandName || '').toLowerCase();
|
|
211
|
+
// const allLists = [];
|
|
212
|
+
// let pageToken = '';
|
|
213
|
+
// const pageSize = 1000;
|
|
214
|
+
//
|
|
215
|
+
// try {
|
|
216
|
+
// while (true) {
|
|
217
|
+
// const url = `${BASE_URL}/marketing/lists?page_size=${pageSize}${pageToken ? `&page_token=${pageToken}` : ''}`;
|
|
218
|
+
// const data = await fetch(url, {
|
|
219
|
+
// response: 'json',
|
|
220
|
+
// headers: headers(),
|
|
221
|
+
// timeout: SENDGRID_TIMEOUT_MS,
|
|
222
|
+
// });
|
|
223
|
+
//
|
|
224
|
+
// if (!data.result || data.result.length === 0) {
|
|
225
|
+
// break;
|
|
226
|
+
// }
|
|
227
|
+
//
|
|
228
|
+
// const matchedList = data.result.find(list =>
|
|
229
|
+
// list.name.toLowerCase() === brandNameLower
|
|
230
|
+
// || list.name.toLowerCase().includes(brandNameLower)
|
|
231
|
+
// || brandNameLower.includes(list.name.toLowerCase())
|
|
232
|
+
// );
|
|
233
|
+
//
|
|
234
|
+
// if (matchedList) {
|
|
235
|
+
// return matchedList.id;
|
|
236
|
+
// }
|
|
237
|
+
//
|
|
238
|
+
// allLists.push(...data.result);
|
|
239
|
+
//
|
|
240
|
+
// if (!data._metadata?.next) {
|
|
241
|
+
// break;
|
|
242
|
+
// }
|
|
243
|
+
//
|
|
244
|
+
// const nextUrl = new URL(data._metadata.next);
|
|
245
|
+
// pageToken = nextUrl.searchParams.get('page_token');
|
|
246
|
+
//
|
|
247
|
+
// if (!pageToken) {
|
|
248
|
+
// break;
|
|
249
|
+
// }
|
|
250
|
+
// }
|
|
251
|
+
//
|
|
252
|
+
// if (allLists.length === 1) {
|
|
253
|
+
// return allLists[0].id;
|
|
254
|
+
// }
|
|
255
|
+
//
|
|
256
|
+
// if (allLists.length > 0) {
|
|
257
|
+
// console.error(`SendGrid: No list matched brand "${brandName}". Available: ${allLists.map(l => l.name).join(', ')}`);
|
|
258
|
+
// }
|
|
259
|
+
// } catch (e) {
|
|
260
|
+
// console.error('SendGrid list lookup error:', e);
|
|
261
|
+
// }
|
|
262
|
+
//
|
|
263
|
+
// return null;
|
|
264
|
+
// }
|
|
265
|
+
|
|
233
266
|
// --- Single Sends (Campaigns) ---
|
|
234
267
|
|
|
235
268
|
/**
|
|
@@ -354,7 +387,7 @@ async function cancelSingleSend(singleSendId) {
|
|
|
354
387
|
method: 'delete',
|
|
355
388
|
response: 'json',
|
|
356
389
|
headers: headers(),
|
|
357
|
-
timeout:
|
|
390
|
+
timeout: SENDGRID_TIMEOUT_MS,
|
|
358
391
|
});
|
|
359
392
|
|
|
360
393
|
return { success: true };
|
|
@@ -375,7 +408,7 @@ async function getSingleSend(singleSendId) {
|
|
|
375
408
|
const data = await fetch(`${BASE_URL}/marketing/singlesends/${singleSendId}`, {
|
|
376
409
|
response: 'json',
|
|
377
410
|
headers: headers(),
|
|
378
|
-
timeout:
|
|
411
|
+
timeout: SENDGRID_TIMEOUT_MS,
|
|
379
412
|
});
|
|
380
413
|
|
|
381
414
|
return data.id ? data : null;
|
|
@@ -400,7 +433,7 @@ async function listSingleSends(options) {
|
|
|
400
433
|
const data = await fetch(url, {
|
|
401
434
|
response: 'json',
|
|
402
435
|
headers: headers(),
|
|
403
|
-
timeout:
|
|
436
|
+
timeout: SENDGRID_TIMEOUT_MS,
|
|
404
437
|
});
|
|
405
438
|
|
|
406
439
|
return data.result || [];
|
|
@@ -437,7 +470,7 @@ async function addContact({ email, firstName, lastName, company, customFields })
|
|
|
437
470
|
}
|
|
438
471
|
}
|
|
439
472
|
|
|
440
|
-
const listId =
|
|
473
|
+
const listId = getListId();
|
|
441
474
|
const result = await upsertContacts({
|
|
442
475
|
contacts: [contact],
|
|
443
476
|
listIds: listId ? [listId] : [],
|
|
@@ -508,7 +541,7 @@ async function getSegmentContacts(segmentId, maxWaitMs = 60000) {
|
|
|
508
541
|
const statusData = await fetch(`${BASE_URL}/marketing/contacts/exports/${exportData.id}`, {
|
|
509
542
|
response: 'json',
|
|
510
543
|
headers: headers(),
|
|
511
|
-
timeout:
|
|
544
|
+
timeout: SENDGRID_TIMEOUT_MS,
|
|
512
545
|
});
|
|
513
546
|
|
|
514
547
|
console.log(`SendGrid getSegmentContacts: Poll #${pollCount} — ${statusData.status} (${Date.now() - startTime}ms)`);
|
|
@@ -138,10 +138,11 @@
|
|
|
138
138
|
marketing: {
|
|
139
139
|
sendgrid: {
|
|
140
140
|
enabled: true,
|
|
141
|
+
listId: '', // SendGrid Marketing list UUID. Populated by OMEGA's sendgrid/ensure/list.js. Skips runtime list-discovery API call.
|
|
141
142
|
},
|
|
142
143
|
beehiiv: {
|
|
143
144
|
enabled: false,
|
|
144
|
-
|
|
145
|
+
publicationId: '', // Beehiiv publication ID (e.g., 'pub_xxxxx'). Populated by OMEGA's beehiiv/ensure/publication.js. Required for marketing sync to Beehiiv.
|
|
145
146
|
// Content pipeline. Lives under the provider that publishes the result —
|
|
146
147
|
// Beehiiv for newsletters, eventually SendGrid for promo blasts. The
|
|
147
148
|
// shape is the same regardless of provider: sources, tone, template,
|
|
@@ -69,7 +69,8 @@ service cloud.firestore {
|
|
|
69
69
|
|| isWritingField('affiliate')
|
|
70
70
|
|| isWritingField('api')
|
|
71
71
|
|| isWritingField('metadata')
|
|
72
|
-
|| isWritingField('usage')
|
|
72
|
+
|| isWritingField('usage')
|
|
73
|
+
|| isWritingField('consent');
|
|
73
74
|
}
|
|
74
75
|
|
|
75
76
|
function isCreatingField(field) {
|
package/test/rules/user.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* - Users can read their own document
|
|
7
7
|
* - Users can write to their own document (non-protected fields only)
|
|
8
8
|
* - Users cannot read/write other users' documents
|
|
9
|
-
* - Protected fields (auth, roles, flags, subscription, affiliate, api, usage) cannot be written by users
|
|
9
|
+
* - Protected fields (auth, roles, flags, subscription, affiliate, api, metadata, usage, consent) cannot be written by users
|
|
10
10
|
*
|
|
11
11
|
* @see templates/firestore.rules
|
|
12
12
|
*/
|
|
@@ -234,6 +234,28 @@ module.exports = {
|
|
|
234
234
|
},
|
|
235
235
|
},
|
|
236
236
|
|
|
237
|
+
// Test 11.5: User cannot write 'consent' field (protected - server-only)
|
|
238
|
+
{
|
|
239
|
+
name: 'user-cannot-write-consent-field',
|
|
240
|
+
auth: 'none',
|
|
241
|
+
|
|
242
|
+
async run({ rules, accounts }) {
|
|
243
|
+
const uid = accounts.basic.uid;
|
|
244
|
+
const db = rules.asAccount('basic');
|
|
245
|
+
|
|
246
|
+
// Should fail - consent is protected (only signup route + webhook
|
|
247
|
+
// processors can mutate it server-side; a client write would let a
|
|
248
|
+
// user retroactively forge their own consent record).
|
|
249
|
+
await rules.expectFailure(
|
|
250
|
+
db.doc(`users/${uid}`).set({
|
|
251
|
+
consent: {
|
|
252
|
+
marketing: { status: 'granted' },
|
|
253
|
+
},
|
|
254
|
+
}, { merge: true })
|
|
255
|
+
);
|
|
256
|
+
},
|
|
257
|
+
},
|
|
258
|
+
|
|
237
259
|
// Test 12: User cannot write to another user's document
|
|
238
260
|
{
|
|
239
261
|
name: 'user-cannot-write-other-doc',
|