backend-manager 5.2.15 → 5.2.17
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 +27 -0
- package/CLAUDE.md +3 -3
- package/README.md +1 -0
- package/docs/admin-post-route.md +2 -0
- package/docs/ai-library.md +17 -0
- package/docs/consent.md +7 -4
- package/docs/marketing-campaigns.md +23 -1
- package/package.json +1 -1
- package/src/cli/commands/base-command.js +30 -4
- package/src/defaults/CLAUDE.md +4 -0
- package/src/manager/events/cron/daily/ghostii-auto-publisher.js +14 -47
- package/src/manager/events/cron/daily/marketing-newsletter-generate.js +3 -1
- package/src/manager/libraries/ai/providers/openai.js +17 -0
- package/src/manager/libraries/content/ghostii.js +100 -0
- package/src/manager/libraries/email/generators/lib/markdown-renderer.js +9 -0
- package/src/manager/libraries/email/generators/newsletter.js +148 -3
- package/src/manager/routes/user/signup/post.js +129 -38
- package/src/manager/schemas/user/signup/post.js +12 -8
- package/src/test/test-accounts.js +25 -0
- package/templates/backend-manager-config.json +9 -2
- package/test/marketing/newsletter-generate.js +70 -7
- package/test/routes/user/signup.js +168 -0
|
@@ -26,6 +26,8 @@ const { generateSectionImage } = require('./lib/svg-illustrator.js');
|
|
|
26
26
|
const { renderNewsletter } = require('./lib/mjml-template.js');
|
|
27
27
|
const { renderMarkdown } = require('./lib/markdown-renderer.js');
|
|
28
28
|
const { uploadAssets, RAW_BASE } = require('./lib/image-host.js');
|
|
29
|
+
const { buildPublicConfig } = require('../../../routes/brand/get.js');
|
|
30
|
+
const { writeArticle, publishArticle } = require('../../../libraries/content/ghostii.js');
|
|
29
31
|
|
|
30
32
|
/**
|
|
31
33
|
* Generate newsletter content from parent server sources.
|
|
@@ -45,6 +47,11 @@ const { uploadAssets, RAW_BASE } = require('./lib/image-host.js');
|
|
|
45
47
|
* @param {object[]} [opts.sources] - Pre-fetched sources (bypasses parent server claim)
|
|
46
48
|
* @param {boolean} [opts.skipClaim] - Don't call PUT to mark sources as used
|
|
47
49
|
* @param {boolean} [opts.skipImages] - Skip SVG/PNG generation (use placeholders)
|
|
50
|
+
* @param {boolean} [opts.publishArticle] - When the linked-article build runs (config.article.enabled),
|
|
51
|
+
* actually COMMIT the post to the website repo via admin/post.
|
|
52
|
+
* When false (default), the article is still generated and its
|
|
53
|
+
* URL computed + injected as the CTA, but nothing is committed.
|
|
54
|
+
* Production cron passes true; the iteration test leaves it false.
|
|
48
55
|
* @returns {object|null} Updated settings with content filled in, or null if unavailable
|
|
49
56
|
*/
|
|
50
57
|
async function generate(Manager, assistant, settings, opts = {}) {
|
|
@@ -147,11 +154,17 @@ async function generate(Manager, assistant, settings, opts = {}) {
|
|
|
147
154
|
|| (sources.length === 1 ? sources[0].id : null)
|
|
148
155
|
|| generatePushId();
|
|
149
156
|
|
|
150
|
-
// 2. SVG illustrations (
|
|
151
|
-
//
|
|
157
|
+
// 2. SVG illustrations + (optional) linked blog article — run CONCURRENTLY.
|
|
158
|
+
// Both are slow AI calls. The image build produces the section image URLs;
|
|
159
|
+
// the article build expands the lead section into a full blog post and
|
|
160
|
+
// returns its public URL, which we inject as a "Read more" CTA before render.
|
|
152
161
|
let imagePaths = [];
|
|
153
162
|
|
|
154
|
-
|
|
163
|
+
const buildImages = async () => {
|
|
164
|
+
if (opts.skipImages) {
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
|
|
155
168
|
const images = await Promise.all(
|
|
156
169
|
structure.sections.map((s) => generateSectionImage({
|
|
157
170
|
imagePrompt: s.image_prompt,
|
|
@@ -191,6 +204,54 @@ async function generate(Manager, assistant, settings, opts = {}) {
|
|
|
191
204
|
|
|
192
205
|
// Stash images on the return for callers that want to access raw buffers
|
|
193
206
|
opts._lastImages = images;
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
// The linked-article build is gated by config.article.enabled and needs a lead
|
|
210
|
+
// section to expand. Wrapped so a failure here NEVER blocks the newsletter —
|
|
211
|
+
// it resolves to null and the CTA simply isn't injected.
|
|
212
|
+
//
|
|
213
|
+
// Two phases, independently controllable:
|
|
214
|
+
// - GENERATE: always runs when article.enabled is on. Calls Ghostii to write
|
|
215
|
+
// the article + hero image and computes the public URL it WOULD live at.
|
|
216
|
+
// - PUBLISH: commits the post to the website repo via admin/post. Only when
|
|
217
|
+
// opts.publishArticle is true. The production cron passes true; the
|
|
218
|
+
// iteration test leaves it false (so it exercises generation without
|
|
219
|
+
// committing a real post) unless NEWSLETTER_CREATE_ARTICLE=1 is set.
|
|
220
|
+
//
|
|
221
|
+
// The CTA URL is derived from the article title (same slugify admin/post uses),
|
|
222
|
+
// so the newsletter links correctly even in generate-only mode.
|
|
223
|
+
const wantArticle = !!config.article?.enabled
|
|
224
|
+
&& Array.isArray(structure.sections)
|
|
225
|
+
&& structure.sections.length > 0;
|
|
226
|
+
|
|
227
|
+
const buildArticle = async () => {
|
|
228
|
+
if (!wantArticle) {
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return buildLinkedArticle({
|
|
233
|
+
Manager,
|
|
234
|
+
assistant,
|
|
235
|
+
brand,
|
|
236
|
+
config,
|
|
237
|
+
structure,
|
|
238
|
+
publish: !!opts.publishArticle,
|
|
239
|
+
}).catch((e) => {
|
|
240
|
+
assistant.error(`Newsletter generator: linked article failed — ${e.message}`);
|
|
241
|
+
return null;
|
|
242
|
+
});
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
const [, articleResult] = await Promise.all([buildImages(), buildArticle()]);
|
|
246
|
+
|
|
247
|
+
// Inject the "Read the full article" CTA onto the lead section BEFORE render.
|
|
248
|
+
// sectionCard (MJML) renders section.cta = { label, url } automatically; the
|
|
249
|
+
// markdown renderer emits it too. The URL is the post's public blog URL —
|
|
250
|
+
// present whether the article was actually published or only generated
|
|
251
|
+
// (derived from the title slug), so the newsletter links correctly either way.
|
|
252
|
+
if (articleResult?.url) {
|
|
253
|
+
structure.sections[0].cta = { label: 'Read the full article', url: articleResult.url };
|
|
254
|
+
assistant.log(`Newsletter generator: linked article ${articleResult.published ? 'published' : 'generated (not published)'} — ${articleResult.url}`);
|
|
194
255
|
}
|
|
195
256
|
|
|
196
257
|
// 3. MJML → HTML
|
|
@@ -342,6 +403,11 @@ async function generate(Manager, assistant, settings, opts = {}) {
|
|
|
342
403
|
})),
|
|
343
404
|
},
|
|
344
405
|
totals: aggregateTotals(filterMeta, structure._meta, opts._lastImages),
|
|
406
|
+
// Linked blog article (when config.article.enabled is on). null if disabled or failed.
|
|
407
|
+
// `published` is false when the article was generated but not committed (e.g. test mode).
|
|
408
|
+
article: articleResult
|
|
409
|
+
? { url: articleResult.url, slug: articleResult.slug, path: articleResult.path, published: !!articleResult.published }
|
|
410
|
+
: null,
|
|
345
411
|
};
|
|
346
412
|
|
|
347
413
|
// Public asset URLs — stamped onto the generated campaign doc by the cron
|
|
@@ -358,6 +424,7 @@ async function generate(Manager, assistant, settings, opts = {}) {
|
|
|
358
424
|
summaryUrl,
|
|
359
425
|
imageUrls: imagePaths,
|
|
360
426
|
beehiivPostId,
|
|
427
|
+
articleUrl: articleResult?.url || null, // linked blog post (config.article.enabled), null otherwise
|
|
361
428
|
tags: Array.isArray(structure.tags) ? structure.tags : [],
|
|
362
429
|
} : null;
|
|
363
430
|
|
|
@@ -375,9 +442,87 @@ async function generate(Manager, assistant, settings, opts = {}) {
|
|
|
375
442
|
images: opts._lastImages || [], // image buffers for the iteration test to persist locally
|
|
376
443
|
assets, // GitHub asset URLs (folder, html, md, summary, images) — null in local mode
|
|
377
444
|
meta, // per-step provider/model/cost/timing telemetry
|
|
445
|
+
article: articleResult || null, // full linked-article result { url, slug, path, published, article: {title, body, headerImageUrl, ...} } — null when disabled/failed
|
|
378
446
|
};
|
|
379
447
|
}
|
|
380
448
|
|
|
449
|
+
/**
|
|
450
|
+
* Expand the newsletter's lead section into a full blog article via Ghostii and
|
|
451
|
+
* (optionally) publish it to the brand's website repo. Returns the post's public
|
|
452
|
+
* URL so the newsletter can link to it via a "Read the full article" CTA.
|
|
453
|
+
*
|
|
454
|
+
* The lead section (structure.sections[0]) is the same topic the newsletter
|
|
455
|
+
* leads with, so the resulting article is the long-form version.
|
|
456
|
+
*
|
|
457
|
+
* Two phases:
|
|
458
|
+
* - GENERATE (always): Ghostii writes the article + hero image. We then compute
|
|
459
|
+
* the public URL the post WOULD live at, using the same title→slug rule
|
|
460
|
+
* admin/post applies. This URL is valid for the CTA whether or not we publish.
|
|
461
|
+
* - PUBLISH (only when `publish`): commit the post to the website repo via
|
|
462
|
+
* admin/post (GitHub). When `publish` is false, nothing is committed.
|
|
463
|
+
*
|
|
464
|
+
* Gated upstream by config.article.enabled. Any failure is caught by the caller
|
|
465
|
+
* and the CTA is simply omitted — the newsletter never depends on this.
|
|
466
|
+
*
|
|
467
|
+
* @param {object} args
|
|
468
|
+
* @param {object} args.Manager
|
|
469
|
+
* @param {object} args.assistant
|
|
470
|
+
* @param {object} args.brand - { id, name, url, ... }
|
|
471
|
+
* @param {object} args.config - marketing.beehiiv.content (tone, instructions, article.author)
|
|
472
|
+
* @param {object} args.structure - newsletter structure (sections[0] is the lead)
|
|
473
|
+
* @param {boolean} [args.publish] - Commit the post to GitHub via admin/post. Default false.
|
|
474
|
+
* @returns {Promise<{url, slug, path, published}|null>}
|
|
475
|
+
*/
|
|
476
|
+
async function buildLinkedArticle({ Manager, assistant, brand, config, structure, publish }) {
|
|
477
|
+
const lead = structure.sections[0] || {};
|
|
478
|
+
const publicConfig = buildPublicConfig(Manager.config);
|
|
479
|
+
|
|
480
|
+
// Build the article brief from the lead section, folded with the shared
|
|
481
|
+
// editorial steer (tone + instructions) so the blog post matches the
|
|
482
|
+
// newsletter's voice. Ghostii expands this into a full article + hero image.
|
|
483
|
+
const briefLines = [
|
|
484
|
+
`Company: ${brand?.name || ''}: ${brand?.description || ''}`,
|
|
485
|
+
config?.tone ? `Tone: ${config.tone}` : '',
|
|
486
|
+
config?.instructions ? `Instructions: ${config.instructions}` : '',
|
|
487
|
+
'',
|
|
488
|
+
`Write a full blog article expanding on this topic:`,
|
|
489
|
+
`Title: ${lead.title || ''}`,
|
|
490
|
+
`Summary: ${lead.body || ''}`,
|
|
491
|
+
].filter(Boolean).join('\n');
|
|
492
|
+
|
|
493
|
+
assistant.log(`Newsletter generator: building linked article for "${lead.title}"`);
|
|
494
|
+
|
|
495
|
+
// Phase 1 — GENERATE (always)
|
|
496
|
+
const article = await writeArticle({
|
|
497
|
+
brand: publicConfig,
|
|
498
|
+
description: briefLines,
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
// Compute the public URL the post WOULD live at. admin/post slugifies the
|
|
502
|
+
// title (after stripping a `blog/` prefix that titles never have), so we
|
|
503
|
+
// mirror that here to derive the same slug without needing to publish.
|
|
504
|
+
const slug = Manager.Utilities().slugify(article.title || '');
|
|
505
|
+
const url = slug
|
|
506
|
+
? `${(publicConfig.brand?.url || '').replace(/\/$/, '')}/blog/${slug}`
|
|
507
|
+
: null;
|
|
508
|
+
|
|
509
|
+
// Phase 2 — PUBLISH (gated). Commit to the website repo only when asked.
|
|
510
|
+
if (!publish) {
|
|
511
|
+
assistant.log(`Newsletter generator: article generated but NOT published (publish=false) — would live at ${url}`);
|
|
512
|
+
return { url, slug, path: null, published: false, article };
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
const result = await publishArticle(assistant, {
|
|
516
|
+
brand: publicConfig,
|
|
517
|
+
article,
|
|
518
|
+
id: Math.round(Date.now() / 1000),
|
|
519
|
+
author: config?.article?.author,
|
|
520
|
+
postPath: 'ghostii',
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
return { url: result.url || url, slug: result.slug || slug, path: result.path, published: true, article };
|
|
524
|
+
}
|
|
525
|
+
|
|
381
526
|
/**
|
|
382
527
|
* Send an internal alert email when Beehiiv draft creation fails so the
|
|
383
528
|
* brand team knows there's a ready newsletter waiting for manual upload.
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
const moment = require('moment');
|
|
2
|
+
const _ = require('lodash');
|
|
2
3
|
const { inferContact } = require('../../../libraries/infer-contact.js');
|
|
3
4
|
const { validate: validateEmail, isDisposable } = require('../../../libraries/email/validation.js');
|
|
4
5
|
|
|
@@ -61,7 +62,14 @@ module.exports = async ({ assistant, user, settings, libraries }) => {
|
|
|
61
62
|
// 4. Gather all data, then write once
|
|
62
63
|
const email = user.auth.email;
|
|
63
64
|
const inferred = await inferUserContact(assistant, email);
|
|
64
|
-
const userRecord = buildUserRecord(assistant,
|
|
65
|
+
const userRecord = buildUserRecord(assistant, {
|
|
66
|
+
settings,
|
|
67
|
+
inferred,
|
|
68
|
+
uid,
|
|
69
|
+
email,
|
|
70
|
+
creationTime: authUser.metadata.creationTime,
|
|
71
|
+
existingDoc: userDoc,
|
|
72
|
+
});
|
|
65
73
|
|
|
66
74
|
assistant.log(`signup(): Writing user record for ${uid}`, userRecord);
|
|
67
75
|
|
|
@@ -111,21 +119,43 @@ async function pollForUserDoc(assistant, uid) {
|
|
|
111
119
|
}
|
|
112
120
|
|
|
113
121
|
/**
|
|
114
|
-
* Build the
|
|
122
|
+
* Build the complete user record to write at signup completion.
|
|
123
|
+
*
|
|
124
|
+
* Returns the WHOLE merged document (written without {merge}), layered deepest-first:
|
|
125
|
+
* 1. Manager.User() full schema shape — guarantees every leaf exists (so a doc created by a
|
|
126
|
+
* partial path, e.g. onCreate never firing, still ends up schema-complete).
|
|
127
|
+
* 2. the existing doc — real values win over the schema defaults, so we never clobber the
|
|
128
|
+
* user's api keys, subscription, roles, affiliate.code, or any custom/non-standard fields.
|
|
129
|
+
* 3. the signup data — attribution / activity / consent / flags / personal we own at signup
|
|
130
|
+
* land on top.
|
|
131
|
+
*
|
|
132
|
+
* Why a full deep-merge instead of `.set(partial, {merge:true})`: Firestore's merge REPLACES a
|
|
133
|
+
* map field rather than deep-merging it, so writing a partial `attribution` flattened onCreate's
|
|
134
|
+
* full attribution object and the OMEGA migration had to re-add every leaf on every signup.
|
|
135
|
+
* Deep-merging in JS and writing the whole doc avoids that entirely.
|
|
115
136
|
*/
|
|
116
|
-
function buildUserRecord(assistant, settings, inferred, creationTime) {
|
|
137
|
+
function buildUserRecord(assistant, { settings, inferred, uid, email, creationTime, existingDoc }) {
|
|
117
138
|
const Manager = assistant.Manager;
|
|
118
|
-
const attribution = settings.attribution;
|
|
119
139
|
|
|
120
|
-
//
|
|
121
|
-
|
|
122
|
-
|
|
140
|
+
// Inferred name/company (from AI/regex on the email) — only set when present.
|
|
141
|
+
const personal = {};
|
|
142
|
+
if (inferred?.firstName || inferred?.lastName) {
|
|
143
|
+
personal.name = {
|
|
144
|
+
...(inferred.firstName ? { first: inferred.firstName } : {}),
|
|
145
|
+
...(inferred.lastName ? { last: inferred.lastName } : {}),
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
if (inferred?.company) {
|
|
149
|
+
personal.company = { name: inferred.company };
|
|
123
150
|
}
|
|
124
151
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
152
|
+
// Layer 1: full schema shape (every leaf present with defaults).
|
|
153
|
+
const schemaShape = Manager.User({ auth: { uid, email } }).properties;
|
|
154
|
+
|
|
155
|
+
// Layer 3: the data signup owns.
|
|
156
|
+
const signupData = {
|
|
157
|
+
auth: { uid, email },
|
|
158
|
+
flags: { signupProcessed: true },
|
|
129
159
|
activity: {
|
|
130
160
|
...settings.context,
|
|
131
161
|
geolocation: {
|
|
@@ -137,37 +167,24 @@ function buildUserRecord(assistant, settings, inferred, creationTime) {
|
|
|
137
167
|
...(settings.context?.client || {}),
|
|
138
168
|
},
|
|
139
169
|
},
|
|
140
|
-
attribution: attribution || {},
|
|
141
|
-
consent: buildConsentRecord(assistant, settings.consent, creationTime),
|
|
170
|
+
attribution: settings.attribution || {},
|
|
171
|
+
consent: buildConsentRecord(assistant, settings.consent, creationTime, existingDoc?.consent),
|
|
142
172
|
metadata: Manager.Metadata().set({ tag: 'user/signup' }),
|
|
173
|
+
...(Object.keys(personal).length ? { personal } : {}),
|
|
143
174
|
};
|
|
144
175
|
|
|
145
|
-
//
|
|
146
|
-
// value. Normally onCreate sets this, but if onCreate didn't fire this merge write is the
|
|
147
|
-
// doc's first creation — without this the doc lands with no created date and the OMEGA
|
|
148
|
-
// migration has to backfill it. Idempotent: when onCreate already wrote it, this matches.
|
|
176
|
+
// metadata.created from Auth's creationTime (canonical), matching onCreate + the migration SSOT.
|
|
149
177
|
if (creationTime) {
|
|
150
178
|
const createdDate = new Date(creationTime);
|
|
151
|
-
|
|
179
|
+
signupData.metadata.created = {
|
|
152
180
|
timestamp: createdDate.toISOString(),
|
|
153
181
|
timestampUNIX: Math.round(createdDate.getTime() / 1000),
|
|
154
182
|
};
|
|
155
183
|
}
|
|
156
184
|
|
|
157
|
-
//
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
...(inferred.firstName || inferred.lastName ? {
|
|
161
|
-
name: {
|
|
162
|
-
...(inferred.firstName ? { first: inferred.firstName } : {}),
|
|
163
|
-
...(inferred.lastName ? { last: inferred.lastName } : {}),
|
|
164
|
-
},
|
|
165
|
-
} : {}),
|
|
166
|
-
...(inferred.company ? { company: { name: inferred.company } } : {}),
|
|
167
|
-
};
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
return record;
|
|
185
|
+
// Deep-merge: schema (base) ← existing doc (real values win) ← signup data (owned fields win).
|
|
186
|
+
// _.merge mutates its first arg, so start from a fresh object.
|
|
187
|
+
return _.merge({}, schemaShape, existingDoc || {}, signupData);
|
|
171
188
|
}
|
|
172
189
|
|
|
173
190
|
/**
|
|
@@ -184,7 +201,7 @@ function buildUserRecord(assistant, settings, inferred, creationTime) {
|
|
|
184
201
|
* record what the client sent, but the route will not have reached this point in practice
|
|
185
202
|
* (the signup-form HTML5-requires the legal checkbox).
|
|
186
203
|
*/
|
|
187
|
-
function buildConsentRecord(assistant, clientConsent, creationTime) {
|
|
204
|
+
function buildConsentRecord(assistant, clientConsent, creationTime, existingConsent) {
|
|
188
205
|
const consent = clientConsent || {};
|
|
189
206
|
const ip = assistant.request.geolocation?.ip || null;
|
|
190
207
|
|
|
@@ -202,7 +219,7 @@ function buildConsentRecord(assistant, clientConsent, creationTime) {
|
|
|
202
219
|
const legalGranted = consent.legal?.granted === true;
|
|
203
220
|
const legalText = typeof consent.legal?.text === 'string' ? consent.legal.text : null;
|
|
204
221
|
|
|
205
|
-
|
|
222
|
+
let legal = legalGranted
|
|
206
223
|
? {
|
|
207
224
|
status: 'granted',
|
|
208
225
|
grantedAt: { timestamp, timestampUNIX, source: 'signup', ip, text: legalText },
|
|
@@ -216,7 +233,7 @@ function buildConsentRecord(assistant, clientConsent, creationTime) {
|
|
|
216
233
|
const marketingGranted = consent.marketing?.granted === true;
|
|
217
234
|
const marketingText = typeof consent.marketing?.text === 'string' ? consent.marketing.text : null;
|
|
218
235
|
|
|
219
|
-
|
|
236
|
+
let marketing = marketingGranted
|
|
220
237
|
? {
|
|
221
238
|
status: 'granted',
|
|
222
239
|
grantedAt: { timestamp, timestampUNIX, source: 'signup', ip, text: marketingText },
|
|
@@ -229,6 +246,19 @@ function buildConsentRecord(assistant, clientConsent, creationTime) {
|
|
|
229
246
|
revokedAt: { timestamp, timestampUNIX, source: 'signup', ip, text: null },
|
|
230
247
|
};
|
|
231
248
|
|
|
249
|
+
// Never DOWNGRADE an existing granted consent. A legacy account (signed up before this
|
|
250
|
+
// flow, flags.signupProcessed never set) re-fires /user/signup on page load with empty
|
|
251
|
+
// consent — which would compute status 'revoked' above and, on a {merge:true} write, wipe
|
|
252
|
+
// out the consent they actually granted months ago. If the existing doc already has a
|
|
253
|
+
// consent granted and the incoming payload doesn't explicitly re-grant it, preserve the
|
|
254
|
+
// existing record. A genuine new grant or an at-signup decline (no prior grant) still applies.
|
|
255
|
+
if (existingConsent?.legal?.status === 'granted' && legal.status !== 'granted') {
|
|
256
|
+
legal = existingConsent.legal;
|
|
257
|
+
}
|
|
258
|
+
if (existingConsent?.marketing?.status === 'granted' && marketing.status !== 'granted') {
|
|
259
|
+
marketing = existingConsent.marketing;
|
|
260
|
+
}
|
|
261
|
+
|
|
232
262
|
assistant.log(`buildConsentRecord: legal=${legal.status}, marketing=${marketing.status} (raw input legal.granted=${consent.legal?.granted}, marketing.granted=${consent.marketing?.granted})`);
|
|
233
263
|
|
|
234
264
|
return { legal, marketing };
|
|
@@ -262,9 +292,7 @@ async function inferUserContact(assistant, email) {
|
|
|
262
292
|
*/
|
|
263
293
|
async function processAffiliate(assistant, uid, email, settings) {
|
|
264
294
|
const { admin } = assistant.Manager.libraries;
|
|
265
|
-
const affiliateCode = settings.attribution?.affiliate?.code
|
|
266
|
-
|| settings.affiliateCode
|
|
267
|
-
|| null;
|
|
295
|
+
const affiliateCode = settings.attribution?.affiliate?.code || null;
|
|
268
296
|
|
|
269
297
|
if (!affiliateCode) {
|
|
270
298
|
return;
|
|
@@ -358,6 +386,7 @@ function sendWelcomeEmails(assistant, uid, firstName) {
|
|
|
358
386
|
}
|
|
359
387
|
|
|
360
388
|
sendWelcomeEmail(assistant, uid, firstName).catch(e => assistant.error('signup(): sendWelcomeEmail failed:', e));
|
|
389
|
+
sendDiscountNudgeEmail(assistant, uid, firstName).catch(e => assistant.error('signup(): sendDiscountNudgeEmail failed:', e));
|
|
361
390
|
sendCheckupEmail(assistant, uid, firstName).catch(e => assistant.error('signup(): sendCheckupEmail failed:', e));
|
|
362
391
|
sendFeedbackEmail(assistant, uid).catch(e => assistant.error('signup(): sendFeedbackEmail failed:', e));
|
|
363
392
|
}
|
|
@@ -405,6 +434,68 @@ Thank you for choosing **${Manager.config.brand.name}**. Here's to new beginning
|
|
|
405
434
|
});
|
|
406
435
|
}
|
|
407
436
|
|
|
437
|
+
/**
|
|
438
|
+
* Send discount-nudge email (24 hours after signup)
|
|
439
|
+
*
|
|
440
|
+
* A warm, personal check-in that offers a discount code in exchange for a reply.
|
|
441
|
+
* Scheduled fire-and-forget via sendAt (same pattern as checkup/feedback) — there is
|
|
442
|
+
* intentionally no premium check at send time, so a user who upgrades within 24h may
|
|
443
|
+
* still receive it. The copy is deliberately worded as a friendly thank-you (not "you
|
|
444
|
+
* haven't upgraded") so it reads fine regardless of the recipient's current plan.
|
|
445
|
+
*
|
|
446
|
+
* The reply itself is the goal: replies are a strong positive sender-reputation signal,
|
|
447
|
+
* and a real human check-in lands in the Primary tab rather than Promotions. Inbound
|
|
448
|
+
* reply handling (auto-issuing the code) is out of scope here — replies are handled
|
|
449
|
+
* separately.
|
|
450
|
+
*
|
|
451
|
+
* Subject is personalized with the recipient's first name when available, and uses
|
|
452
|
+
* intrigue framing ("something for you 🎁") rather than spam-trigger words ("free",
|
|
453
|
+
* "claim", "bonus") to protect deliverability.
|
|
454
|
+
*/
|
|
455
|
+
function sendDiscountNudgeEmail(assistant, uid, firstName) {
|
|
456
|
+
const Manager = assistant.Manager;
|
|
457
|
+
const mailer = Manager.Email(assistant);
|
|
458
|
+
const greeting = firstName ? `Hey ${firstName}` : 'Hey there';
|
|
459
|
+
const subject = firstName
|
|
460
|
+
? `${firstName}, I've got something for you 🎁`
|
|
461
|
+
: `I've got something for you 🎁`;
|
|
462
|
+
|
|
463
|
+
return mailer.send({
|
|
464
|
+
to: uid,
|
|
465
|
+
sender: 'hello',
|
|
466
|
+
categories: ['engagement/discount-nudge'],
|
|
467
|
+
subject: subject,
|
|
468
|
+
template: 'default',
|
|
469
|
+
copy: false,
|
|
470
|
+
sendAt: moment().add(24, 'hours').unix(),
|
|
471
|
+
data: {
|
|
472
|
+
email: {
|
|
473
|
+
preview: `Just checking in from ${Manager.config.brand.name} — and I've got a little thank-you for you.`,
|
|
474
|
+
},
|
|
475
|
+
body: {
|
|
476
|
+
title: `How's it going?`,
|
|
477
|
+
message: `${greeting},
|
|
478
|
+
|
|
479
|
+
It's Ian, the founder of **${Manager.config.brand.name}**.
|
|
480
|
+
|
|
481
|
+
As a thank-you for giving us a try, I'd love to send you a code for a **premium upgrade**. **Just reply to this email** and I'll get one over to you.
|
|
482
|
+
|
|
483
|
+
I read every reply, so if you have any questions, feedback, or there's anything I can help with, this is the place. Looking forward to hearing from you!`,
|
|
484
|
+
},
|
|
485
|
+
signoff: {
|
|
486
|
+
type: 'personal',
|
|
487
|
+
name: 'Ian Wiedenman, CEO',
|
|
488
|
+
url: `https://ianwiedenman.com?utm_source=discount-nudge-email&utm_medium=email&utm_campaign=${Manager.config.brand.id}`,
|
|
489
|
+
urlText: '@ianwieds',
|
|
490
|
+
},
|
|
491
|
+
},
|
|
492
|
+
})
|
|
493
|
+
.then((result) => {
|
|
494
|
+
assistant.log('sendDiscountNudgeEmail(): Success', result.status);
|
|
495
|
+
return result;
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
|
|
408
499
|
/**
|
|
409
500
|
* Send checkup email (7 days after signup)
|
|
410
501
|
*/
|
|
@@ -4,11 +4,6 @@ module.exports = ({ user }) => ({
|
|
|
4
4
|
default: user?.auth?.uid,
|
|
5
5
|
required: false,
|
|
6
6
|
},
|
|
7
|
-
affiliateCode: {
|
|
8
|
-
types: ['string'],
|
|
9
|
-
default: undefined,
|
|
10
|
-
required: false,
|
|
11
|
-
},
|
|
12
7
|
attribution: {
|
|
13
8
|
types: ['object'],
|
|
14
9
|
default: {},
|
|
@@ -19,9 +14,18 @@ module.exports = ({ user }) => ({
|
|
|
19
14
|
default: {},
|
|
20
15
|
required: false,
|
|
21
16
|
},
|
|
17
|
+
// Consent decision captured at signup. Each sub-object is OPTIONAL — if the client omits
|
|
18
|
+
// `legal`/`marketing` (e.g. a legacy account re-firing /user/signup on page load with no
|
|
19
|
+
// fresh consent), the route leaves that consent untouched rather than downgrading it.
|
|
20
|
+
// When present, `granted` is the decision and `text` is the exact copy shown to the user.
|
|
22
21
|
consent: {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
22
|
+
legal: {
|
|
23
|
+
granted: { types: ['boolean'], required: false },
|
|
24
|
+
text: { types: ['string'], required: false },
|
|
25
|
+
},
|
|
26
|
+
marketing: {
|
|
27
|
+
granted: { types: ['boolean'], required: false },
|
|
28
|
+
text: { types: ['string'], required: false },
|
|
29
|
+
},
|
|
26
30
|
},
|
|
27
31
|
});
|
|
@@ -193,6 +193,31 @@ const STATIC_ACCOUNTS = {
|
|
|
193
193
|
subscription: { product: { id: 'basic' }, status: 'active' },
|
|
194
194
|
},
|
|
195
195
|
},
|
|
196
|
+
// Used to verify the never-downgrade guard: the test seeds this account's doc with already-
|
|
197
|
+
// granted consent, then re-fires /user/signup with an empty consent payload and asserts the
|
|
198
|
+
// grant is preserved (not flipped to revoked). Dedicated account so the seeded state is isolated.
|
|
199
|
+
'consent-preserve': {
|
|
200
|
+
id: 'consent-preserve',
|
|
201
|
+
uid: '_test-consent-preserve',
|
|
202
|
+
email: '_test.consent-preserve@{domain}',
|
|
203
|
+
properties: {
|
|
204
|
+
roles: {},
|
|
205
|
+
subscription: { product: { id: 'basic' }, status: 'active' },
|
|
206
|
+
},
|
|
207
|
+
},
|
|
208
|
+
// Used to verify buildUserRecord's layered deep-merge: the test seeds this account's doc with
|
|
209
|
+
// real values (api keys, paid subscription, admin role, a custom non-schema field) + a partial
|
|
210
|
+
// attribution, fires /user/signup, and asserts the merge PRESERVES those real/custom values
|
|
211
|
+
// while still filling every schema leaf and applying the signup data on top.
|
|
212
|
+
'signup-merge': {
|
|
213
|
+
id: 'signup-merge',
|
|
214
|
+
uid: '_test-signup-merge',
|
|
215
|
+
email: '_test.signup-merge@{domain}',
|
|
216
|
+
properties: {
|
|
217
|
+
roles: {},
|
|
218
|
+
subscription: { product: { id: 'basic' }, status: 'active' },
|
|
219
|
+
},
|
|
220
|
+
},
|
|
196
221
|
};
|
|
197
222
|
|
|
198
223
|
/**
|
|
@@ -153,6 +153,10 @@
|
|
|
153
153
|
instructions: '', // free-form text passed to the AI ("focus on X", "avoid Y", brand voice notes)
|
|
154
154
|
tone: 'professional', // 'professional', 'casual', 'actionable', 'witty', etc. — passed to AI prompt
|
|
155
155
|
template: 'clean', // 'clean' | 'editorial' | 'field-report' — layout template (each owns its own content shape and aesthetic)
|
|
156
|
+
article: {
|
|
157
|
+
enabled: false, // when true, the newsletter's lead section is expanded into a full blog post via Ghostii, published to the website repo, and linked from the newsletter with a "Read the full article" CTA
|
|
158
|
+
author: null, // author slug for the linked article (Ghostii/admin-post picks a default if unset)
|
|
159
|
+
},
|
|
156
160
|
theme: {
|
|
157
161
|
primaryColor: '#5B5BFF', // accent color: buttons, links, brand text
|
|
158
162
|
secondaryColor: '#1E1E2A', // body text color
|
|
@@ -192,9 +196,12 @@
|
|
|
192
196
|
appId: '1:123:web:456',
|
|
193
197
|
measurementId: 'G-0123456789',
|
|
194
198
|
},
|
|
199
|
+
// Standalone Ghostii article publisher (daily cron). OPT-IN: disabled by
|
|
200
|
+
// default (articles: 0). Set articles >= 1 to auto-publish independent blog
|
|
201
|
+
// posts. For newsletter-linked articles, use marketing.beehiiv.content.article.enabled instead.
|
|
195
202
|
ghostii: [
|
|
196
203
|
{
|
|
197
|
-
articles:
|
|
204
|
+
articles: 0,
|
|
198
205
|
sources: [
|
|
199
206
|
'$app',
|
|
200
207
|
// Add more sources here
|
|
@@ -204,7 +211,7 @@
|
|
|
204
211
|
],
|
|
205
212
|
prompt: '',
|
|
206
213
|
chance: 1.0,
|
|
207
|
-
author:
|
|
214
|
+
author: null,
|
|
208
215
|
// app: 'other-app-id', // Optional: target a different app
|
|
209
216
|
// appUrl: 'https://api.otherapp.com', // Required if app is set (fetches /backend-manager/app)
|
|
210
217
|
}
|