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.
@@ -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 (parallel) + upload PNGs first so we have URLs
151
- // available to embed in the HTML render below.
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
- if (!opts.skipImages) {
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, settings, inferred, authUser.metadata.creationTime);
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 full user record: client details, attribution, and inferred contact
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
- // Legacy support: if affiliateCode exists, normalize to new format
121
- if (settings.affiliateCode && !attribution.affiliate?.code) {
122
- attribution.affiliate = { code: settings.affiliateCode };
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
- const record = {
126
- flags: {
127
- signupProcessed: true,
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
- // Stamp metadata.created from Firebase Auth's creationTime so it matches Auth's canonical
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
- record.metadata.created = {
179
+ signupData.metadata.created = {
152
180
  timestamp: createdDate.toISOString(),
153
181
  timestampUNIX: Math.round(createdDate.getTime() / 1000),
154
182
  };
155
183
  }
156
184
 
157
- // Add inferred name/company if available
158
- if (inferred) {
159
- record.personal = {
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
- const legal = legalGranted
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
- const marketing = marketingGranted
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
- types: ['object'],
24
- default: {},
25
- required: false,
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: 1,
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: 'alex-raeburn',
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
  }