@edgedev/firebase 2.1.79 → 2.2.79

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/src/cms.js ADDED
@@ -0,0 +1,2358 @@
1
+ const axios = require('axios')
2
+ const {
3
+ logger,
4
+ admin,
5
+ db,
6
+ pubsub,
7
+ onCall,
8
+ HttpsError,
9
+ onDocumentUpdated,
10
+ onDocumentWritten,
11
+ onDocumentDeleted,
12
+ onDocumentCreated,
13
+ onMessagePublished,
14
+ onRequest,
15
+ Firestore,
16
+ permissionCheck,
17
+ } = require('./config.js')
18
+
19
+ const { createKvMirrorHandler } = require('./kv/kvMirror')
20
+
21
+ const SITE_AI_TOPIC = 'site-ai-bootstrap'
22
+ const OPENAI_API_KEY = process.env.OPENAI_API_KEY || ''
23
+ const OPENAI_MODEL = process.env.OPENAI_MODEL || 'gpt-4o-mini'
24
+ const HISTORY_API_KEY = process.env.HISTORY_API_KEY || ''
25
+ const SENDGRID_API_KEY = process.env.SENDGRID_API_KEY || ''
26
+ const SENDGRID_FROM_EMAIL = process.env.SENDGRID_FROM_EMAIL || ''
27
+ const CF_ACCOUNT_ID = process.env.CF_ACCOUNT_ID || ''
28
+ const CLOUDFLARE_PAGES_API_TOKEN = process.env.CLOUDFLARE_PAGES_API_TOKEN || ''
29
+ const CLOUDFLARE_PAGES_PROJECT = process.env.CLOUDFLARE_PAGES_PROJECT || ''
30
+ const DOMAIN_REGISTRY_COLLECTION = 'domain-registry'
31
+
32
+ const SITE_STRUCTURED_DATA_TEMPLATE = JSON.stringify({
33
+ '@context': 'https://schema.org',
34
+ '@type': 'WebSite',
35
+ '@id': '{{cms-site}}#website',
36
+ 'name': '',
37
+ 'url': '{{cms-site}}',
38
+ 'description': '',
39
+ 'publisher': {
40
+ '@type': 'Organization',
41
+ 'name': '',
42
+ 'logo': {
43
+ '@type': 'ImageObject',
44
+ 'url': '{{cms-logo}}',
45
+ },
46
+ },
47
+ 'sameAs': [],
48
+ }, null, 2)
49
+
50
+ const PAGE_STRUCTURED_DATA_TEMPLATE = JSON.stringify({
51
+ '@context': 'https://schema.org',
52
+ '@type': 'WebPage',
53
+ '@id': '{{cms-site}}#webpage',
54
+ 'name': '',
55
+ 'url': '{{cms-url}}',
56
+ 'description': '',
57
+ 'isPartOf': {
58
+ '@id': '{{cms-site}}#website',
59
+ },
60
+ }, null, 2)
61
+
62
+ const sleep = ms => new Promise(resolve => setTimeout(resolve, ms))
63
+
64
+ const allowCors = (req, res) => {
65
+ res.set('Access-Control-Allow-Origin', '*')
66
+ res.set('Access-Control-Allow-Methods', 'POST, OPTIONS')
67
+ res.set('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-API-Key')
68
+ if (req.method === 'OPTIONS') {
69
+ res.status(204).send('')
70
+ return true
71
+ }
72
+ return false
73
+ }
74
+
75
+ const parseBody = (req) => {
76
+ if (!req?.body)
77
+ return null
78
+ if (typeof req.body === 'object')
79
+ return req.body
80
+ if (typeof req.body === 'string') {
81
+ try {
82
+ return JSON.parse(req.body)
83
+ }
84
+ catch {
85
+ return null
86
+ }
87
+ }
88
+ return null
89
+ }
90
+
91
+ const getForwardedFor = (req) => {
92
+ const forwarded = req.headers['x-forwarded-for']
93
+ if (!forwarded)
94
+ return ''
95
+ if (Array.isArray(forwarded))
96
+ return forwarded.join(', ')
97
+ return String(forwarded)
98
+ }
99
+
100
+ const getClientIp = (req) => {
101
+ const forwarded = getForwardedFor(req)
102
+ if (forwarded)
103
+ return forwarded.split(',')[0].trim()
104
+ return req.ip || req.connection?.remoteAddress || req.socket?.remoteAddress || ''
105
+ }
106
+
107
+ const parseBrowser = (ua) => {
108
+ if (!ua)
109
+ return ''
110
+ if (/Edg\//i.test(ua))
111
+ return 'Edge'
112
+ if (/OPR\//i.test(ua))
113
+ return 'Opera'
114
+ if (/Chrome\//i.test(ua))
115
+ return 'Chrome'
116
+ if (/Firefox\//i.test(ua))
117
+ return 'Firefox'
118
+ if (/Safari\//i.test(ua) && /Version\//i.test(ua))
119
+ return 'Safari'
120
+ return 'Other'
121
+ }
122
+
123
+ const parseOs = (ua) => {
124
+ if (!ua)
125
+ return ''
126
+ if (/Windows NT/i.test(ua))
127
+ return 'Windows'
128
+ if (/Mac OS X/i.test(ua))
129
+ return 'macOS'
130
+ if (/Android/i.test(ua))
131
+ return 'Android'
132
+ if (/iPhone|iPad|iPod/i.test(ua))
133
+ return 'iOS'
134
+ if (/Linux/i.test(ua))
135
+ return 'Linux'
136
+ return 'Other'
137
+ }
138
+
139
+ const SITE_USER_META_FIELDS = [
140
+ 'contactEmail',
141
+ 'contactPhone',
142
+ 'socialFacebook',
143
+ 'socialInstagram',
144
+ 'socialTwitter',
145
+ 'socialLinkedIn',
146
+ 'socialYouTube',
147
+ 'socialTikTok',
148
+ ]
149
+
150
+ const pickSyncFields = (source = {}) => {
151
+ const payload = {}
152
+ for (const field of SITE_USER_META_FIELDS) {
153
+ payload[field] = source?.[field] ?? ''
154
+ }
155
+ return payload
156
+ }
157
+
158
+ const buildUpdateDiff = (current = {}, next = {}) => {
159
+ const update = {}
160
+ for (const [key, value] of Object.entries(next)) {
161
+ if (current?.[key] !== value) {
162
+ update[key] = value
163
+ }
164
+ }
165
+ return update
166
+ }
167
+
168
+ const resolveStagedUserRef = async (userIdOrDocId) => {
169
+ if (!userIdOrDocId)
170
+ return null
171
+
172
+ const byDocRef = db.collection('staged-users').doc(userIdOrDocId)
173
+ const byDocSnap = await byDocRef.get()
174
+ if (byDocSnap.exists)
175
+ return byDocRef
176
+
177
+ const querySnap = await db.collection('staged-users')
178
+ .where('userId', '==', userIdOrDocId)
179
+ .limit(1)
180
+ .get()
181
+
182
+ if (querySnap.empty)
183
+ return null
184
+
185
+ return querySnap.docs[0].ref
186
+ }
187
+
188
+ const parseDevice = (ua, headers) => {
189
+ const mobileHint = headers['sec-ch-ua-mobile']
190
+ if (mobileHint === '?1')
191
+ return 'mobile'
192
+ if (mobileHint === '?0')
193
+ return 'desktop'
194
+ if (/Mobile|Android|iPhone|iPad|iPod/i.test(ua || ''))
195
+ return 'mobile'
196
+ return 'desktop'
197
+ }
198
+
199
+ const getOrgIdFromPath = (path) => {
200
+ const trimmed = String(path || '').split('?')[0]
201
+ const parts = trimmed.split('/').filter(Boolean)
202
+ if (parts[0] !== 'api' || parts[1] !== 'history')
203
+ return ''
204
+ return parts[2] || ''
205
+ }
206
+
207
+ const getApiKey = (req) => {
208
+ const headers = req.headers || {}
209
+ const headerKey = String(headers['x-api-key'] || '').trim()
210
+ const authHeader = String(headers.authorization || '').trim()
211
+ if (authHeader.toLowerCase().startsWith('bearer '))
212
+ return authHeader.slice(7).trim()
213
+ return headerKey
214
+ }
215
+
216
+ const normalizeEmail = (value) => {
217
+ if (!value)
218
+ return ''
219
+ const trimmed = String(value).trim()
220
+ return trimmed.includes('@') ? trimmed : ''
221
+ }
222
+
223
+ const normalizeDomain = (value) => {
224
+ if (!value)
225
+ return ''
226
+ let normalized = String(value).trim().toLowerCase()
227
+ if (!normalized)
228
+ return ''
229
+ if (normalized.includes('://')) {
230
+ try {
231
+ normalized = new URL(normalized).host
232
+ }
233
+ catch {
234
+ normalized = normalized.split('://').pop() || normalized
235
+ }
236
+ }
237
+ normalized = normalized.split('/')[0] || ''
238
+ if (normalized.includes(':') && !normalized.startsWith('[')) {
239
+ normalized = normalized.split(':')[0] || ''
240
+ }
241
+ return normalized.replace(/\.+$/g, '')
242
+ }
243
+
244
+ const shouldSyncCloudflareDomain = (domain) => {
245
+ if (!domain)
246
+ return false
247
+ if (domain.includes('localhost'))
248
+ return false
249
+ if (CLOUDFLARE_PAGES_PROJECT) {
250
+ const pagesDomain = `${CLOUDFLARE_PAGES_PROJECT}.pages.dev`
251
+ if (domain === pagesDomain || domain === `www.${pagesDomain}`)
252
+ return false
253
+ }
254
+ return true
255
+ }
256
+
257
+ const getCloudflarePagesDomain = (domain) => {
258
+ if (!domain)
259
+ return ''
260
+ if (domain.startsWith('www.'))
261
+ return domain
262
+ return `www.${domain}`
263
+ }
264
+
265
+ const isCloudflareDomainAlreadyExistsError = (status, errors = [], message = '') => {
266
+ if (status === 409)
267
+ return true
268
+ const errorMessages = errors.map(err => String(err?.message || '').toLowerCase())
269
+ if (errorMessages.some(text => text.includes('already exists')))
270
+ return true
271
+ if (errorMessages.some(text => text.includes('already added')))
272
+ return true
273
+ const lowerMessage = String(message || '').toLowerCase()
274
+ return lowerMessage.includes('already exists') || lowerMessage.includes('already added')
275
+ }
276
+
277
+ const addCloudflarePagesDomain = async (domain, context = {}) => {
278
+ if (!CF_ACCOUNT_ID || !CLOUDFLARE_PAGES_API_TOKEN || !CLOUDFLARE_PAGES_PROJECT) {
279
+ logger.warn('Cloudflare Pages domain sync skipped: missing env vars', {
280
+ domain,
281
+ missingAccount: !CF_ACCOUNT_ID,
282
+ missingToken: !CLOUDFLARE_PAGES_API_TOKEN,
283
+ missingProject: !CLOUDFLARE_PAGES_PROJECT,
284
+ ...context,
285
+ })
286
+ return { ok: false, error: 'Cloudflare Pages env vars missing.' }
287
+ }
288
+
289
+ const url = `https://api.cloudflare.com/client/v4/accounts/${CF_ACCOUNT_ID}/pages/projects/${CLOUDFLARE_PAGES_PROJECT}/domains`
290
+ try {
291
+ const response = await axios.post(url, { name: domain }, {
292
+ headers: {
293
+ 'Authorization': `Bearer ${CLOUDFLARE_PAGES_API_TOKEN}`,
294
+ 'Content-Type': 'application/json',
295
+ },
296
+ })
297
+ if (response?.data?.success) {
298
+ logger.log('Cloudflare Pages domain added', { domain, ...context })
299
+ return { ok: true }
300
+ }
301
+ logger.warn('Cloudflare Pages domain add response not successful', {
302
+ domain,
303
+ errors: response?.data?.errors || [],
304
+ ...context,
305
+ })
306
+ return { ok: false, error: 'Cloudflare Pages domain add response not successful.' }
307
+ }
308
+ catch (error) {
309
+ const status = error?.response?.status || 0
310
+ const errors = error?.response?.data?.errors || []
311
+ const message = error?.message || 'Unknown error'
312
+ const alreadyExists = isCloudflareDomainAlreadyExistsError(status, errors, message)
313
+ if (alreadyExists) {
314
+ logger.log('Cloudflare Pages domain already exists', { domain, ...context })
315
+ return { ok: true }
316
+ }
317
+ logger.error('Cloudflare Pages domain add error', { domain, status, errors, message, ...context })
318
+ const errorMessage = errors.length
319
+ ? errors.map(err => err?.message).filter(Boolean).join('; ')
320
+ : message
321
+ return { ok: false, error: errorMessage || 'Cloudflare Pages domain add error.' }
322
+ }
323
+ }
324
+
325
+ const removeCloudflarePagesDomain = async (domain, context = {}) => {
326
+ if (!CF_ACCOUNT_ID || !CLOUDFLARE_PAGES_API_TOKEN || !CLOUDFLARE_PAGES_PROJECT) {
327
+ logger.warn('Cloudflare Pages domain removal skipped: missing env vars', {
328
+ domain,
329
+ missingAccount: !CF_ACCOUNT_ID,
330
+ missingToken: !CLOUDFLARE_PAGES_API_TOKEN,
331
+ missingProject: !CLOUDFLARE_PAGES_PROJECT,
332
+ ...context,
333
+ })
334
+ return { ok: false, error: 'Cloudflare Pages env vars missing.' }
335
+ }
336
+
337
+ const url = `https://api.cloudflare.com/client/v4/accounts/${CF_ACCOUNT_ID}/pages/projects/${CLOUDFLARE_PAGES_PROJECT}/domains/${domain}`
338
+ try {
339
+ const response = await axios.delete(url, {
340
+ headers: {
341
+ 'Authorization': `Bearer ${CLOUDFLARE_PAGES_API_TOKEN}`,
342
+ 'Content-Type': 'application/json',
343
+ },
344
+ })
345
+ if (response?.data?.success) {
346
+ logger.log('Cloudflare Pages domain removed', { domain, ...context })
347
+ return { ok: true }
348
+ }
349
+ logger.warn('Cloudflare Pages domain removal response not successful', {
350
+ domain,
351
+ errors: response?.data?.errors || [],
352
+ ...context,
353
+ })
354
+ return { ok: false, error: 'Cloudflare Pages domain removal response not successful.' }
355
+ }
356
+ catch (error) {
357
+ const status = error?.response?.status || 0
358
+ const errors = error?.response?.data?.errors || []
359
+ const message = error?.message || 'Unknown error'
360
+ const alreadyMissing = status === 404
361
+ || errors.some(err => String(err?.message || '').toLowerCase().includes('not found'))
362
+ if (alreadyMissing) {
363
+ logger.log('Cloudflare Pages domain already removed', { domain, ...context })
364
+ return { ok: true }
365
+ }
366
+ logger.error('Cloudflare Pages domain removal error', { domain, status, errors, message, ...context })
367
+ const errorMessage = errors.length
368
+ ? errors.map(err => err?.message).filter(Boolean).join('; ')
369
+ : message
370
+ return { ok: false, error: errorMessage || 'Cloudflare Pages domain removal error.' }
371
+ }
372
+ }
373
+
374
+ const collectFormEntries = (data) => {
375
+ if (!data || typeof data !== 'object')
376
+ return []
377
+
378
+ const entries = []
379
+ const seen = new Set()
380
+ const ignore = new Set(['orgId', 'siteId', 'pageId', 'blockId'])
381
+
382
+ const addEntry = (key, value) => {
383
+ if (!key)
384
+ return
385
+ const normalizedKey = String(key).trim()
386
+ if (!normalizedKey)
387
+ return
388
+ const lowerKey = normalizedKey.toLowerCase()
389
+ if (ignore.has(normalizedKey) || ignore.has(lowerKey))
390
+ return
391
+ if (value === undefined || value === null || value === '')
392
+ return
393
+ if (seen.has(lowerKey))
394
+ return
395
+ entries.push({ key: normalizedKey, value })
396
+ seen.add(lowerKey)
397
+ }
398
+
399
+ const addArrayFields = (fields) => {
400
+ if (!Array.isArray(fields))
401
+ return
402
+ for (const field of fields) {
403
+ if (!field)
404
+ continue
405
+ const name = field.field || field.name || field.fieldName || field.label || field.title
406
+ const value = field.value ?? field.fieldValue ?? field.val
407
+ addEntry(name, value)
408
+ }
409
+ }
410
+
411
+ addArrayFields(data.fields)
412
+ addArrayFields(data.formFields)
413
+ addArrayFields(data.formData)
414
+
415
+ if (data.fields && typeof data.fields === 'object' && !Array.isArray(data.fields)) {
416
+ for (const [key, value] of Object.entries(data.fields)) {
417
+ addEntry(key, value)
418
+ }
419
+ }
420
+
421
+ for (const [key, value] of Object.entries(data)) {
422
+ if (key === 'fields' || key === 'formFields' || key === 'formData')
423
+ continue
424
+ addEntry(key, value)
425
+ }
426
+
427
+ return entries
428
+ }
429
+
430
+ const getReplyToEmail = (data, entries) => {
431
+ if (data && typeof data === 'object') {
432
+ const directKey = Object.keys(data).find(key => key.toLowerCase() === 'email')
433
+ if (directKey) {
434
+ const direct = normalizeEmail(data[directKey])
435
+ if (direct)
436
+ return direct
437
+ }
438
+ }
439
+
440
+ const entry = entries.find(item => item.key.toLowerCase() === 'email')
441
+ return normalizeEmail(entry?.value)
442
+ }
443
+
444
+ const escapeHtml = (value) => {
445
+ return String(value)
446
+ .replace(/&/g, '&')
447
+ .replace(/</g, '&lt;')
448
+ .replace(/>/g, '&gt;')
449
+ .replace(/"/g, '&quot;')
450
+ .replace(/'/g, '&#39;')
451
+ }
452
+
453
+ const formatValue = (value) => {
454
+ if (value === undefined || value === null)
455
+ return ''
456
+ if (typeof value === 'string')
457
+ return value
458
+ if (typeof value === 'number' || typeof value === 'boolean')
459
+ return String(value)
460
+ try {
461
+ return JSON.stringify(value, null, 2)
462
+ }
463
+ catch {
464
+ return String(value)
465
+ }
466
+ }
467
+
468
+ const getPublishedEmailTo = async (orgId, siteId, pageId, blockId) => {
469
+ if (!orgId || !siteId || !pageId || !blockId)
470
+ return ''
471
+ const publishedRef = db.collection('organizations').doc(orgId).collection('sites').doc(siteId).collection('published').doc(pageId)
472
+ const snap = await publishedRef.get()
473
+ if (!snap.exists)
474
+ return ''
475
+ const data = snap.data() || {}
476
+ const content = Array.isArray(data.content) ? data.content : []
477
+ const block = content.find(item => String(item?.id || '') === blockId || String(item?.blockId || '') === blockId)
478
+ if (!block)
479
+ return ''
480
+ const emailTo = block?.values?.emailTo || block?.emailTo || ''
481
+ return String(emailTo || '').trim()
482
+ }
483
+
484
+ const getSiteSettingsEmail = async (orgId, siteId) => {
485
+ if (!orgId || !siteId)
486
+ return ''
487
+ const publishedRef = db.collection('organizations').doc(orgId).collection('published-site-settings').doc(siteId)
488
+ const publishedSnap = await publishedRef.get()
489
+ const publishedEmail = normalizeEmail(publishedSnap?.data()?.contactEmail)
490
+ if (publishedEmail)
491
+ return publishedEmail
492
+ const siteRef = db.collection('organizations').doc(orgId).collection('sites').doc(siteId)
493
+ const siteSnap = await siteRef.get()
494
+ return normalizeEmail(siteSnap?.data()?.contactEmail)
495
+ }
496
+
497
+ const sendContactFormEmail = async ({
498
+ to,
499
+ replyTo,
500
+ entries,
501
+ orgId,
502
+ siteId,
503
+ pageId,
504
+ blockId,
505
+ }) => {
506
+ if (!SENDGRID_API_KEY || !SENDGRID_FROM_EMAIL) {
507
+ logger.error('SendGrid config missing')
508
+ return
509
+ }
510
+
511
+ const fieldLines = entries.length
512
+ ? entries.map(entry => `- ${entry.key}: ${formatValue(entry.value)}`)
513
+ : ['- (no fields provided)']
514
+ const textBody = fieldLines.join('\n')
515
+
516
+ const htmlFields = entries.length
517
+ ? entries
518
+ .map(entry => `<li><strong>${escapeHtml(entry.key)}:</strong> ${escapeHtml(formatValue(entry.value))}</li>`)
519
+ .join('')
520
+ : '<li>(no fields provided)</li>'
521
+ const htmlBody = `
522
+ <div>
523
+ <h2>Contact Form Submission</h2>
524
+ <ul>${htmlFields}</ul>
525
+ </div>
526
+ `
527
+
528
+ await axios.post('https://api.sendgrid.com/v3/mail/send', {
529
+ personalizations: [{ to: [{ email: to }], subject: 'Contact Form Submission' }],
530
+ from: { email: SENDGRID_FROM_EMAIL },
531
+ reply_to: { email: replyTo || SENDGRID_FROM_EMAIL },
532
+ content: [
533
+ { type: 'text/plain', value: textBody },
534
+ { type: 'text/html', value: htmlBody },
535
+ ],
536
+ }, {
537
+ headers: {
538
+ 'Authorization': `Bearer ${SENDGRID_API_KEY}`,
539
+ 'Content-Type': 'application/json',
540
+ },
541
+ })
542
+ }
543
+
544
+ exports.trackHistory = onRequest(async (req, res) => {
545
+ if (allowCors(req, res))
546
+ return
547
+
548
+ if (req.method !== 'POST') {
549
+ res.status(405).json({ error: 'Method not allowed' })
550
+ return
551
+ }
552
+
553
+ if (!HISTORY_API_KEY) {
554
+ logger.error('HISTORY_API_KEY not configured')
555
+ res.status(500).json({ error: 'Server misconfigured' })
556
+ return
557
+ }
558
+
559
+ if (getApiKey(req) !== HISTORY_API_KEY) {
560
+ res.status(401).json({ error: 'Unauthorized' })
561
+ return
562
+ }
563
+
564
+ const orgId = getOrgIdFromPath(req.path || req.url || '')
565
+ if (!orgId) {
566
+ res.status(400).json({ error: 'Missing org id in route' })
567
+ return
568
+ }
569
+
570
+ const payload = parseBody(req)
571
+ if (!payload) {
572
+ res.status(400).json({ error: 'Invalid JSON payload' })
573
+ return
574
+ }
575
+
576
+ const uuid = typeof payload.uuid === 'string' ? payload.uuid.trim() : ''
577
+ const action = typeof payload.action === 'string' ? payload.action.trim() : ''
578
+ const data = payload.data ?? null
579
+
580
+ if (!action) {
581
+ res.status(400).json({ error: 'Missing action' })
582
+ return
583
+ }
584
+
585
+ const historyRef = db.collection('organizations').doc(orgId).collection('lead-history')
586
+ const docRef = uuid ? historyRef.doc(uuid) : historyRef.doc()
587
+ const now = Firestore.FieldValue.serverTimestamp()
588
+
589
+ const headers = req.headers || {}
590
+ const userAgent = String(headers['user-agent'] || '')
591
+ const historyBase = {
592
+ ip: getClientIp(req),
593
+ ipForwardedFor: getForwardedFor(req),
594
+ userAgent,
595
+ browser: parseBrowser(userAgent),
596
+ os: parseOs(userAgent),
597
+ device: parseDevice(userAgent, headers),
598
+ platformHint: String(headers['sec-ch-ua-platform'] || ''),
599
+ browserHint: String(headers['sec-ch-ua'] || ''),
600
+ acceptLanguage: String(headers['accept-language'] || ''),
601
+ referrer: String(headers.referer || headers.referrer || ''),
602
+ }
603
+
604
+ try {
605
+ let exists = false
606
+ if (uuid) {
607
+ const snap = await docRef.get()
608
+ exists = snap.exists
609
+ }
610
+
611
+ const updateData = {
612
+ ...historyBase,
613
+ updatedAt: now,
614
+ lastActionAt: now,
615
+ lastAction: action,
616
+ lastData: data,
617
+ }
618
+ if (!exists) {
619
+ updateData.createdAt = now
620
+ updateData.firstActionAt = now
621
+ }
622
+
623
+ await docRef.set(updateData, { merge: true })
624
+ await docRef.collection('actions').add({
625
+ action,
626
+ data,
627
+ timestamp: now,
628
+ })
629
+ const siteId = typeof data?.siteId === 'string' ? data.siteId.trim() : ''
630
+ if (siteId) {
631
+ await db.collection('organizations').doc(orgId)
632
+ .collection('sites').doc(siteId)
633
+ .collection('lead-actions')
634
+ .add({
635
+ action,
636
+ data,
637
+ timestamp: now,
638
+ uuid: docRef.id,
639
+ })
640
+ }
641
+
642
+ if (action === 'Contact Form' && data && typeof data === 'object') {
643
+ const siteId = typeof data.siteId === 'string' ? data.siteId.trim() : ''
644
+ const pageId = typeof data.pageId === 'string' ? data.pageId.trim() : ''
645
+ const blockId = typeof data.blockId === 'string' ? data.blockId.trim() : ''
646
+
647
+ if (!siteId) {
648
+ logger.warn('Contact form missing siteId', { siteId, pageId, blockId })
649
+ }
650
+ else {
651
+ try {
652
+ const entries = collectFormEntries(data)
653
+ const replyTo = getReplyToEmail(data, entries)
654
+ const blockEmail = normalizeEmail(await getPublishedEmailTo(orgId, siteId, pageId, blockId))
655
+ const fallbackEmail = await getSiteSettingsEmail(orgId, siteId)
656
+ const emailTo = blockEmail || fallbackEmail
657
+
658
+ if (!emailTo) {
659
+ logger.warn('Contact form email not found', { orgId, siteId, pageId, blockId })
660
+ }
661
+ else {
662
+ await sendContactFormEmail({
663
+ to: emailTo,
664
+ replyTo,
665
+ entries,
666
+ orgId,
667
+ siteId,
668
+ pageId,
669
+ blockId,
670
+ })
671
+ }
672
+ }
673
+ catch (err) {
674
+ logger.error('Contact form email failed', err)
675
+ }
676
+ }
677
+ }
678
+
679
+ res.json({ uuid: docRef.id })
680
+ }
681
+ catch (err) {
682
+ logger.error('trackHistory failed', err)
683
+ res.status(500).json({ error: 'Failed to record history' })
684
+ }
685
+ })
686
+
687
+ const getTimestampMillis = (value) => {
688
+ if (!value)
689
+ return null
690
+ if (typeof value.toMillis === 'function')
691
+ return value.toMillis()
692
+ if (typeof value === 'number')
693
+ return value
694
+ if (typeof value === 'string') {
695
+ const parsed = Date.parse(value)
696
+ return Number.isNaN(parsed) ? null : parsed
697
+ }
698
+ if (typeof value === 'object') {
699
+ if (admin?.firestore?.Timestamp && value instanceof admin.firestore.Timestamp)
700
+ return value.toMillis()
701
+ if (typeof value.seconds === 'number' && typeof value.nanoseconds === 'number')
702
+ return value.seconds * 1000 + value.nanoseconds / 1e6
703
+ }
704
+ return null
705
+ }
706
+
707
+ const cloneValue = (val) => {
708
+ if (val === null || typeof val !== 'object')
709
+ return val
710
+ if (admin?.firestore?.Timestamp && val instanceof admin.firestore.Timestamp)
711
+ return val
712
+ if (val instanceof Date)
713
+ return new Date(val.getTime())
714
+ if (Array.isArray(val))
715
+ return val.map(cloneValue)
716
+ const cloned = {}
717
+ for (const [key, value] of Object.entries(val)) {
718
+ cloned[key] = cloneValue(value)
719
+ }
720
+ return cloned
721
+ }
722
+
723
+ const replaceSyncedBlockIfOlder = (blocks, blockId, sourceBlock, sourceMillis) => {
724
+ let updated = false
725
+ for (let i = 0; i < blocks.length; i++) {
726
+ const currentBlock = blocks[i]
727
+ if (currentBlock?.blockId !== blockId)
728
+ continue
729
+ const currentMillis = getTimestampMillis(currentBlock.blockUpdatedAt)
730
+ if (currentMillis !== null && currentMillis >= sourceMillis)
731
+ continue
732
+ const cloned = cloneValue(sourceBlock)
733
+ // Preserve the per-page block instance id so layout references remain valid.
734
+ cloned.id = currentBlock?.id || cloned.id
735
+ blocks[i] = cloned
736
+ updated = true
737
+ }
738
+ return updated
739
+ }
740
+
741
+ const collectSyncedBlocks = (content, postContent) => {
742
+ const syncedBlocks = new Map()
743
+ const blocks = [
744
+ ...(Array.isArray(content) ? content : []),
745
+ ...(Array.isArray(postContent) ? postContent : []),
746
+ ]
747
+
748
+ for (const block of blocks) {
749
+ if (!block?.synced || !block.blockId)
750
+ continue
751
+ const millis = getTimestampMillis(block.blockUpdatedAt)
752
+ if (millis === null)
753
+ continue
754
+ const existing = syncedBlocks.get(block.blockId)
755
+ if (!existing || millis > existing.millis)
756
+ syncedBlocks.set(block.blockId, { block, millis })
757
+ }
758
+
759
+ return syncedBlocks
760
+ }
761
+
762
+ const BLOCK_META_EXCLUDE_KEYS = new Set(['queryItems', 'limit'])
763
+
764
+ const updateBlocksInArray = (blocks, blockId, afterData) => {
765
+ let touched = false
766
+ for (const block of blocks) {
767
+ if (block?.blockId !== blockId)
768
+ continue
769
+
770
+ if (afterData.content !== undefined)
771
+ block.content = afterData.content
772
+
773
+ block.meta = block.meta || {}
774
+ const srcMeta = afterData.meta || {}
775
+ for (const key of Object.keys(srcMeta)) {
776
+ block.meta[key] = block.meta[key] || {}
777
+ const src = srcMeta[key] || {}
778
+ for (const metaKey of Object.keys(src)) {
779
+ if (BLOCK_META_EXCLUDE_KEYS.has(metaKey))
780
+ continue
781
+ block.meta[key][metaKey] = src[metaKey]
782
+ }
783
+ }
784
+
785
+ touched = true
786
+ }
787
+ return touched
788
+ }
789
+
790
+ const buildPageBlockUpdate = (pageData, blockId, afterData) => {
791
+ const pageContent = Array.isArray(pageData.content) ? [...pageData.content] : []
792
+ const pagePostContent = Array.isArray(pageData.postContent) ? [...pageData.postContent] : []
793
+
794
+ const contentTouched = updateBlocksInArray(pageContent, blockId, afterData)
795
+ const postContentTouched = updateBlocksInArray(pagePostContent, blockId, afterData)
796
+
797
+ return {
798
+ touched: contentTouched || postContentTouched,
799
+ content: pageContent,
800
+ postContent: pagePostContent,
801
+ }
802
+ }
803
+
804
+ exports.blockUpdated = onDocumentUpdated({ document: 'organizations/{orgId}/blocks/{blockId}', timeoutSeconds: 180 }, async (event) => {
805
+ const change = event.data
806
+ const blockId = event.params.blockId
807
+ const orgId = event.params.orgId
808
+ const afterData = change.after.data() || {}
809
+
810
+ const sites = await db.collection('organizations').doc(orgId).collection('sites').get()
811
+ if (sites.empty)
812
+ logger.log(`No sites found in org ${orgId}`)
813
+
814
+ const processedSiteIds = new Set()
815
+
816
+ const updatePagesForSite = async (siteId, { updatePublished = true, scopeLabel }) => {
817
+ const pagesSnap = await db.collection('organizations').doc(orgId)
818
+ .collection('sites').doc(siteId)
819
+ .collection('pages')
820
+ .where('blockIds', 'array-contains', blockId)
821
+ .get()
822
+
823
+ if (pagesSnap.empty) {
824
+ logger.log(`No pages found using block ${blockId} in ${scopeLabel}`)
825
+ return
826
+ }
827
+
828
+ for (const pageDoc of pagesSnap.docs) {
829
+ const pageData = pageDoc.data() || {}
830
+ const { touched, content, postContent } = buildPageBlockUpdate(pageData, blockId, afterData)
831
+
832
+ if (!touched) {
833
+ logger.log(`Page ${pageDoc.id} has no matching block ${blockId} in ${scopeLabel}`)
834
+ continue
835
+ }
836
+
837
+ await pageDoc.ref.update({ content, postContent })
838
+
839
+ if (updatePublished) {
840
+ const publishedRef = db.collection('organizations').doc(orgId)
841
+ .collection('sites').doc(siteId)
842
+ .collection('published').doc(pageDoc.id)
843
+
844
+ const publishedDoc = await publishedRef.get()
845
+ if (publishedDoc.exists) {
846
+ await publishedRef.update({ content, postContent })
847
+ }
848
+ }
849
+
850
+ logger.log(`Updated page ${pageDoc.id} in ${scopeLabel} with new block ${blockId} content`)
851
+ }
852
+ }
853
+
854
+ for (const siteDoc of sites.docs) {
855
+ const siteId = siteDoc.id
856
+ processedSiteIds.add(siteId)
857
+ await updatePagesForSite(siteId, {
858
+ updatePublished: siteId !== 'templates',
859
+ scopeLabel: siteId === 'templates'
860
+ ? `templates site (org ${orgId})`
861
+ : `site ${siteId} (org ${orgId})`,
862
+ })
863
+ }
864
+
865
+ if (!processedSiteIds.has('templates')) {
866
+ await updatePagesForSite('templates', {
867
+ updatePublished: false,
868
+ scopeLabel: `templates site (org ${orgId})`,
869
+ })
870
+ }
871
+ })
872
+
873
+ exports.fontFileUpdated = onDocumentUpdated({ document: 'organizations/{orgId}/files/{fileId}', timeoutSeconds: 180 }, async (event) => {
874
+ const before = event.data.before.data() || {}
875
+ const after = event.data.after.data() || {}
876
+ const orgId = event.params.orgId
877
+
878
+ if (!after?.uploadCompletedToR2 || !after?.r2URL)
879
+ return
880
+
881
+ // Only act on font uploads that were tagged for themes
882
+ const meta = after.meta || {}
883
+ const themeId = meta.themeId
884
+ if (!themeId || !meta.cmsFont)
885
+ return
886
+
887
+ if (meta.autoLink === false)
888
+ return
889
+
890
+ if (before.uploadCompletedToR2 === after.uploadCompletedToR2 && before.r2URL === after.r2URL)
891
+ return
892
+
893
+ try {
894
+ const themeRef = db.collection('organizations').doc(orgId).collection('themes').doc(themeId)
895
+ const themeSnap = await themeRef.get()
896
+ if (!themeSnap.exists) {
897
+ logger.warn(`fontFileUpdated: theme ${themeId} not found in org ${orgId}`)
898
+ return
899
+ }
900
+
901
+ const themeData = themeSnap.data() || {}
902
+ let headJson = {}
903
+ try {
904
+ headJson = JSON.parse(themeData.headJSON || '{}') || {}
905
+ }
906
+ catch (e) {
907
+ headJson = {}
908
+ }
909
+
910
+ const links = Array.isArray(headJson.link) ? [...headJson.link] : []
911
+ const href = after.r2URL
912
+ const alreadyLinked = links.some(link => link && link.href === href)
913
+ if (alreadyLinked)
914
+ return
915
+
916
+ const linkEntry = {
917
+ rel: 'preload',
918
+ as: 'font',
919
+ href,
920
+ crossorigin: '',
921
+ }
922
+ if (after.contentType)
923
+ linkEntry.type = after.contentType
924
+
925
+ links.push(linkEntry)
926
+ headJson.link = links
927
+
928
+ await themeRef.set({
929
+ headJSON: JSON.stringify(headJson, null, 2),
930
+ }, { merge: true })
931
+
932
+ logger.log(`fontFileUpdated: appended font link for ${href} to theme ${themeId} in org ${orgId}`)
933
+ }
934
+ catch (error) {
935
+ logger.error('fontFileUpdated error', error)
936
+ }
937
+ })
938
+
939
+ const slug = s => String(s || '').trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 80)
940
+ const yyyyMM = (d) => {
941
+ const dt = d ? new Date(d) : new Date()
942
+ const y = dt.getUTCFullYear()
943
+ const m = String(dt.getUTCMonth() + 1).padStart(2, '0')
944
+ const h = String(dt.getUTCHours()).padStart(2, '0')
945
+ const min = String(dt.getUTCMinutes()).padStart(2, '0')
946
+ return `${y}${m}${h}${min}`
947
+ }
948
+ // Canonical + indices for posts
949
+ exports.onPostWritten = createKvMirrorHandler({
950
+ document: 'organizations/{orgId}/sites/{siteId}/published_posts/{postId}',
951
+
952
+ makeCanonicalKey: ({ orgId, siteId, postId }) =>
953
+ `posts:${orgId}:${siteId}:${postId}`,
954
+
955
+ makeIndexKeys: ({ orgId, siteId, postId }, data) => {
956
+ const keys = []
957
+
958
+ // by tag
959
+ const tags = Array.isArray(data?.tags) ? data.tags : []
960
+ for (const t of tags) {
961
+ const st = slug(t)
962
+ if (st)
963
+ keys.push(`idx:posts:tags:${orgId}:${siteId}:${st}:${postId}`)
964
+ }
965
+
966
+ // by date (archive buckets)
967
+ const pub = data?.publishedAt || data?.doc_created_at || data?.createdAt || null
968
+ if (pub)
969
+ keys.push(`idx:posts:dates:${orgId}:${siteId}:${yyyyMM(pub)}:${postId}`)
970
+
971
+ // by slug (direct lookup)
972
+ if (data?.name)
973
+ keys.push(`idx:posts:slugs:${orgId}:${siteId}:${data.name}`)
974
+
975
+ return keys
976
+ },
977
+
978
+ // store full document as-is
979
+ serialize: data => JSON.stringify(data),
980
+
981
+ // tiny metadata so you can render lists without N GETs (stored in meta:{key})
982
+ makeMetadata: data => ({
983
+ title: data?.title || '',
984
+ blurb: data?.blurb || '',
985
+ doc_created_at: data?.doc_created_at || '',
986
+ featuredImage: data?.featuredImage || '',
987
+ name: data?.name || '',
988
+ }),
989
+
990
+ timeoutSeconds: 180,
991
+ })
992
+
993
+ exports.onSiteWritten = createKvMirrorHandler({
994
+ document: 'organizations/{orgId}/published-site-settings/{siteId}',
995
+ makeCanonicalKey: ({ orgId, siteId }) =>
996
+ `sites:${orgId}:${siteId}`,
997
+ makeIndexKeys: ({ orgId }, data) => {
998
+ const keys = []
999
+ const domains = Array.isArray(data?.domains) ? data.domains : []
1000
+ for (const domain of domains) {
1001
+ const st = slug(domain)
1002
+ keys.push(`idx:sites:domains:${st}`)
1003
+ }
1004
+ return keys
1005
+ },
1006
+ serialize: data => JSON.stringify(data),
1007
+ timeoutSeconds: 180,
1008
+ })
1009
+
1010
+ exports.onUserWritten = createKvMirrorHandler({
1011
+ document: 'organizations/{orgId}/users/{userId}',
1012
+ makeCanonicalKey: ({ orgId, userId }) =>
1013
+ `users:${orgId}:${userId}`,
1014
+ makeIndexKeys: async ({ orgId, userId }, data) => {
1015
+ const keys = []
1016
+ const resolvedUserId = slug(data?.userId) || slug(userId)
1017
+ if (resolvedUserId)
1018
+ keys.push(`idx:users:userId:${orgId}:${resolvedUserId}`)
1019
+ return keys
1020
+ },
1021
+ serialize: data => JSON.stringify(data),
1022
+ makeMetadata: data => ({
1023
+ title: data?.title || '',
1024
+ contactPhone: data?.contactPhone || data?.phone || '',
1025
+ contactEmail: data?.contactEmail || data?.email || '',
1026
+ doc_created_at: data?.doc_created_at || '',
1027
+ featuredImage: data?.featuredImage || '',
1028
+ name: data?.name || '',
1029
+ }),
1030
+ timeoutSeconds: 180,
1031
+ })
1032
+
1033
+ exports.onThemeWritten = createKvMirrorHandler({
1034
+ document: 'organizations/{orgId}/themes/{themeId}',
1035
+ makeCanonicalKey: ({ orgId, themeId }) =>
1036
+ `themes:${orgId}:${themeId}`,
1037
+ serialize: data => JSON.stringify({ theme: JSON.parse(data.theme), headJSON: JSON.parse(data.headJSON), extraCSS: data.extraCSS }),
1038
+ timeoutSeconds: 180,
1039
+ })
1040
+
1041
+ exports.onPublishedPageWritten = createKvMirrorHandler({
1042
+ document: 'organizations/{orgId}/sites/{siteId}/published/{pageId}',
1043
+ makeCanonicalKey: ({ orgId, siteId, pageId }) =>
1044
+ `pages:${orgId}:${siteId}:${pageId}`,
1045
+ timeoutSeconds: 180,
1046
+ })
1047
+
1048
+ exports.syncPublishedSyncedBlocks = onDocumentWritten({ document: 'organizations/{orgId}/sites/{siteId}/published/{pageId}', timeoutSeconds: 180 }, async (event) => {
1049
+ const change = event.data
1050
+ if (!change.after.exists)
1051
+ return
1052
+
1053
+ const orgId = event.params.orgId
1054
+ const siteId = event.params.siteId
1055
+ const pageId = event.params.pageId
1056
+ const data = change.after.data() || {}
1057
+ const syncedBlocks = collectSyncedBlocks(data.content, data.postContent)
1058
+
1059
+ if (!syncedBlocks.size)
1060
+ return
1061
+
1062
+ const publishedRef = db.collection('organizations').doc(orgId).collection('sites').doc(siteId).collection('published')
1063
+ const draftRef = db.collection('organizations').doc(orgId).collection('sites').doc(siteId).collection('pages')
1064
+
1065
+ for (const [blockId, { block: sourceBlock, millis: sourceMillis }] of syncedBlocks.entries()) {
1066
+ const publishedSnap = await publishedRef.where('blockIds', 'array-contains', blockId).get()
1067
+ if (publishedSnap.empty)
1068
+ continue
1069
+
1070
+ for (const publishedDoc of publishedSnap.docs) {
1071
+ if (publishedDoc.id === pageId)
1072
+ continue
1073
+
1074
+ const publishedData = publishedDoc.data() || {}
1075
+ const publishedContent = Array.isArray(publishedData.content) ? [...publishedData.content] : []
1076
+ const publishedPostContent = Array.isArray(publishedData.postContent) ? [...publishedData.postContent] : []
1077
+
1078
+ const updatedContent = replaceSyncedBlockIfOlder(publishedContent, blockId, sourceBlock, sourceMillis)
1079
+ const updatedPostContent = replaceSyncedBlockIfOlder(publishedPostContent, blockId, sourceBlock, sourceMillis)
1080
+
1081
+ if (!updatedContent && !updatedPostContent)
1082
+ continue
1083
+
1084
+ await publishedDoc.ref.update({ content: publishedContent, postContent: publishedPostContent })
1085
+
1086
+ logger.log(`Synced published block ${blockId} from page ${pageId} to published page ${publishedDoc.id} in site ${siteId} (org ${orgId})`)
1087
+ }
1088
+
1089
+ const draftSnap = await draftRef.where('blockIds', 'array-contains', blockId).get()
1090
+ if (!draftSnap.empty) {
1091
+ for (const draftDoc of draftSnap.docs) {
1092
+ if (draftDoc.id === pageId)
1093
+ continue
1094
+
1095
+ const draftData = draftDoc.data() || {}
1096
+ const draftContent = Array.isArray(draftData.content) ? [...draftData.content] : []
1097
+ const draftPostContent = Array.isArray(draftData.postContent) ? [...draftData.postContent] : []
1098
+
1099
+ const updatedDraftContent = replaceSyncedBlockIfOlder(draftContent, blockId, sourceBlock, sourceMillis)
1100
+ const updatedDraftPostContent = replaceSyncedBlockIfOlder(draftPostContent, blockId, sourceBlock, sourceMillis)
1101
+
1102
+ if (!updatedDraftContent && !updatedDraftPostContent)
1103
+ continue
1104
+
1105
+ await draftDoc.ref.update({ content: draftContent, postContent: draftPostContent })
1106
+ logger.log(`Synced published block ${blockId} from page ${pageId} to draft page ${draftDoc.id} in site ${siteId} (org ${orgId})`)
1107
+ }
1108
+ }
1109
+ }
1110
+ })
1111
+
1112
+ exports.onPageUpdated = onDocumentWritten({ document: 'organizations/{orgId}/sites/{siteId}/pages/{pageId}', timeoutSeconds: 180 }, async (event) => {
1113
+ const change = event.data
1114
+ const orgId = event.params.orgId
1115
+ const siteId = event.params.siteId
1116
+ const pageId = event.params.pageId
1117
+ const siteRef = db.collection('organizations').doc(orgId).collection('sites').doc(siteId)
1118
+ const pageData = change.after.exists ? (change.after.data() || {}) : {}
1119
+
1120
+ if (change.after.exists) {
1121
+ const lastModified = pageData.last_updated
1122
+ ?? pageData.doc_updated_at
1123
+ ?? pageData.updatedAt
1124
+ ?? pageData.doc_created_at
1125
+ ?? (change.after.updateTime ? change.after.updateTime.toMillis() : Date.now())
1126
+
1127
+ await siteRef.set({
1128
+ pageLastModified: {
1129
+ [pageId]: lastModified,
1130
+ },
1131
+ }, { merge: true })
1132
+ }
1133
+
1134
+ if (!change.after.exists)
1135
+ return
1136
+
1137
+ const content = Array.isArray(pageData.content) ? pageData.content : []
1138
+ const postContent = Array.isArray(pageData.postContent) ? pageData.postContent : []
1139
+
1140
+ const syncedBlocks = collectSyncedBlocks(content, postContent)
1141
+
1142
+ if (!syncedBlocks.size)
1143
+ return
1144
+
1145
+ const pagesRef = db.collection('organizations').doc(orgId).collection('sites').doc(siteId).collection('pages')
1146
+
1147
+ for (const [blockId, { block: sourceBlock, millis: sourceMillis }] of syncedBlocks.entries()) {
1148
+ const pagesSnap = await pagesRef.where('blockIds', 'array-contains', blockId).get()
1149
+ if (pagesSnap.empty)
1150
+ continue
1151
+
1152
+ for (const pageDoc of pagesSnap.docs) {
1153
+ if (pageDoc.id === pageId)
1154
+ continue
1155
+
1156
+ const pageData = pageDoc.data() || {}
1157
+ const pageContent = Array.isArray(pageData.content) ? [...pageData.content] : []
1158
+ const pagePostContent = Array.isArray(pageData.postContent) ? [...pageData.postContent] : []
1159
+
1160
+ const updatedContent = replaceSyncedBlockIfOlder(pageContent, blockId, sourceBlock, sourceMillis)
1161
+ const updatedPostContent = replaceSyncedBlockIfOlder(pagePostContent, blockId, sourceBlock, sourceMillis)
1162
+
1163
+ if (!updatedContent && !updatedPostContent)
1164
+ continue
1165
+
1166
+ await pageDoc.ref.update({ content: pageContent, postContent: pagePostContent })
1167
+
1168
+ logger.log(`Synced block ${blockId} to page ${pageDoc.id} in site ${siteId} (org ${orgId})`)
1169
+ }
1170
+ }
1171
+ })
1172
+
1173
+ exports.onPageDeleted = onDocumentDeleted({ document: 'organizations/{orgId}/sites/{siteId}/pages/{pageId}', timeoutSeconds: 180 }, async (event) => {
1174
+ const orgId = event.params.orgId
1175
+ const siteId = event.params.siteId
1176
+ const pageId = event.params.pageId
1177
+ const publishedRef = db.collection('organizations').doc(orgId).collection('sites').doc(siteId).collection('published').doc(pageId)
1178
+ await publishedRef.delete()
1179
+ const siteRef = db.collection('organizations').doc(orgId).collection('sites').doc(siteId)
1180
+ try {
1181
+ await siteRef.update({
1182
+ [`pageLastModified.${pageId}`]: Firestore.FieldValue.delete(),
1183
+ })
1184
+ }
1185
+ catch (error) {
1186
+ logger.warn('Failed to remove pageLastModified for deleted page', { orgId, siteId, pageId, error: error?.message })
1187
+ }
1188
+ })
1189
+
1190
+ exports.onSiteDeleted = onDocumentDeleted({ document: 'organizations/{orgId}/sites/{siteId}', timeoutSeconds: 180 }, async (event) => {
1191
+ // delete documents in sites/{siteId}/published
1192
+ const orgId = event.params.orgId
1193
+ const siteId = event.params.siteId
1194
+
1195
+ // delete documents in sites/{siteId}/pages
1196
+ const pagesRef = db.collection('organizations').doc(orgId).collection('sites').doc(siteId).collection('pages')
1197
+ const pagesDocs = await pagesRef.listDocuments()
1198
+ for (const doc of pagesDocs) {
1199
+ await doc.delete()
1200
+ }
1201
+
1202
+ // delete the published-site-settings document
1203
+ const siteSettingsRef = db.collection('organizations').doc(orgId).collection('published-site-settings').doc(siteId)
1204
+ await siteSettingsRef.delete()
1205
+ })
1206
+
1207
+ const isFillableMeta = (meta) => {
1208
+ if (!meta)
1209
+ return false
1210
+ if (meta.api || meta.collection)
1211
+ return false
1212
+ return true
1213
+ }
1214
+
1215
+ const normalizeOptionValue = (value, options = [], valueKey = 'value', titleKey = 'title') => {
1216
+ if (value === null || value === undefined)
1217
+ return null
1218
+ const stringVal = String(value).trim().toLowerCase()
1219
+ for (const option of options) {
1220
+ const optValue = option?.[valueKey]
1221
+ const optTitle = option?.[titleKey]
1222
+ if (stringVal === String(optValue).trim().toLowerCase() || stringVal === String(optTitle).trim().toLowerCase())
1223
+ return optValue
1224
+ }
1225
+ return null
1226
+ }
1227
+
1228
+ const sanitizeArrayWithSchema = (schema = [], arr) => {
1229
+ if (!Array.isArray(arr))
1230
+ return []
1231
+ return arr
1232
+ .map((item) => {
1233
+ if (!item || typeof item !== 'object')
1234
+ return null
1235
+ const clean = {}
1236
+ for (const schemaItem of schema) {
1237
+ const val = item[schemaItem.field]
1238
+ if (val === null || val === undefined)
1239
+ continue
1240
+ if (typeof val === 'string')
1241
+ clean[schemaItem.field] = val
1242
+ else if (typeof val === 'number')
1243
+ clean[schemaItem.field] = val
1244
+ else if (typeof val === 'boolean')
1245
+ clean[schemaItem.field] = val
1246
+ else
1247
+ clean[schemaItem.field] = JSON.stringify(val)
1248
+ }
1249
+ return Object.keys(clean).length ? clean : null
1250
+ })
1251
+ .filter(Boolean)
1252
+ }
1253
+
1254
+ const sanitizeValueForMeta = (type, value, meta) => {
1255
+ switch (type) {
1256
+ case 'number': {
1257
+ const num = Number(value)
1258
+ return Number.isFinite(num) ? num : null
1259
+ }
1260
+ case 'json': {
1261
+ if (value == null)
1262
+ return null
1263
+ if (typeof value === 'object')
1264
+ return JSON.stringify(value)
1265
+ const str = String(value).trim()
1266
+ if (!str)
1267
+ return null
1268
+ try {
1269
+ JSON.parse(str)
1270
+ return str
1271
+ }
1272
+ catch {
1273
+ return str
1274
+ }
1275
+ }
1276
+ case 'array': {
1277
+ if (meta?.schema)
1278
+ return sanitizeArrayWithSchema(meta.schema, value)
1279
+ if (!Array.isArray(value))
1280
+ return []
1281
+ return value.map(v => String(v || '')).filter(Boolean)
1282
+ }
1283
+ case 'option': {
1284
+ if (meta?.option?.options)
1285
+ return normalizeOptionValue(value, meta.option.options, meta.option.optionsValue, meta.option.optionsKey)
1286
+ return typeof value === 'string' ? value : null
1287
+ }
1288
+ case 'richtext':
1289
+ case 'textarea':
1290
+ case 'text':
1291
+ default:
1292
+ return typeof value === 'string' ? value : ((value === null || value === undefined) ? null : String(value))
1293
+ }
1294
+ }
1295
+
1296
+ const clampText = (value, max) => {
1297
+ if (!value)
1298
+ return ''
1299
+ const str = String(value).replace(/\s+/g, ' ').trim()
1300
+ if (str.length <= max)
1301
+ return str
1302
+ return `${str.slice(0, max)}...`
1303
+ }
1304
+
1305
+ const normalizePromptValue = (value) => {
1306
+ if (value === null || value === undefined)
1307
+ return ''
1308
+ if (typeof value === 'string')
1309
+ return value.trim()
1310
+ if (typeof value === 'number' || typeof value === 'boolean')
1311
+ return String(value)
1312
+ try {
1313
+ return JSON.stringify(value)
1314
+ }
1315
+ catch {
1316
+ return String(value)
1317
+ }
1318
+ }
1319
+
1320
+ const summarizeBlocksForSeo = (blocks = []) => {
1321
+ if (!Array.isArray(blocks) || blocks.length === 0)
1322
+ return ''
1323
+ const summaries = []
1324
+ blocks.forEach((block, index) => {
1325
+ const values = block?.values || {}
1326
+ const lines = []
1327
+ const blockLabel = block?.name || block?.title || block?.heading || block?.label || block?.blockId || block?.id || ''
1328
+
1329
+ const inlineFields = {
1330
+ name: block?.name,
1331
+ title: block?.title,
1332
+ heading: block?.heading,
1333
+ label: block?.label,
1334
+ text: block?.text,
1335
+ body: block?.body,
1336
+ content: block?.content,
1337
+ }
1338
+
1339
+ for (const [key, val] of Object.entries(inlineFields)) {
1340
+ const normalized = normalizePromptValue(val)
1341
+ if (!normalized)
1342
+ continue
1343
+ lines.push(`- ${key}: ${clampText(normalized, 280)}`)
1344
+ }
1345
+
1346
+ for (const [key, val] of Object.entries(values)) {
1347
+ const normalized = normalizePromptValue(val)
1348
+ if (!normalized)
1349
+ continue
1350
+ lines.push(`- ${key}: ${clampText(normalized, 280)}`)
1351
+ }
1352
+ if (!lines.length)
1353
+ return
1354
+ const label = blockLabel || `block-${index + 1}`
1355
+ summaries.push(`Block ${index + 1} (${label})\n${lines.join('\n')}`)
1356
+ })
1357
+ return summaries.join('\n\n')
1358
+ }
1359
+
1360
+ const shouldUpdateSiteStructuredData = (siteData = {}) => {
1361
+ const raw = siteData?.structuredData
1362
+ if (!raw || (typeof raw === 'string' && !raw.trim()))
1363
+ return true
1364
+ let parsed = null
1365
+ if (typeof raw === 'string') {
1366
+ try {
1367
+ parsed = JSON.parse(raw)
1368
+ }
1369
+ catch {
1370
+ return false
1371
+ }
1372
+ }
1373
+ else if (typeof raw === 'object') {
1374
+ parsed = raw
1375
+ }
1376
+ if (!parsed)
1377
+ return false
1378
+ const name = String(parsed.name || '').trim()
1379
+ const url = String(parsed.url || '').trim()
1380
+ const description = String(parsed.description || '').trim()
1381
+ const publisherName = String(parsed.publisher?.name || '').trim()
1382
+ const logoUrl = String(parsed.publisher?.logo?.url || '').trim()
1383
+ const sameAs = Array.isArray(parsed.sameAs) ? parsed.sameAs.filter(Boolean) : []
1384
+ return !name && !url && !description && !publisherName && !logoUrl && sameAs.length === 0
1385
+ }
1386
+
1387
+ const callOpenAiForPageSeo = async ({
1388
+ siteData,
1389
+ pageData,
1390
+ pageId,
1391
+ blockSummary,
1392
+ includeSiteStructuredData,
1393
+ }) => {
1394
+ if (!OPENAI_API_KEY)
1395
+ throw new Error('OPENAI_API_KEY not set')
1396
+
1397
+ const pageStructuredTemplate = PAGE_STRUCTURED_DATA_TEMPLATE
1398
+ const siteStructuredTemplate = includeSiteStructuredData ? SITE_STRUCTURED_DATA_TEMPLATE : ''
1399
+
1400
+ const responseShape = includeSiteStructuredData
1401
+ ? '{"metaTitle":"...","metaDescription":"...","structuredData":{...},"siteStructuredData":{...}}'
1402
+ : '{"metaTitle":"...","metaDescription":"...","structuredData":{...}}'
1403
+
1404
+ const system = [
1405
+ 'You are an SEO assistant updating a CMS page.',
1406
+ 'Use the provided page content and block values.',
1407
+ 'Base the meta description and structured data description on the block content summary.',
1408
+ 'Return JSON only using the specified response shape.',
1409
+ 'Meta title: concise, <= 60 characters.',
1410
+ 'Meta description: <= 160 characters, sentence case.',
1411
+ 'Structured data must match the provided template shape.',
1412
+ 'Preserve CMS tokens like {{cms-site}}, {{cms-url}}, and {{cms-logo}} exactly as-is.',
1413
+ ].join(' ')
1414
+
1415
+ const user = [
1416
+ `Site name: ${siteData?.name || 'n/a'}`,
1417
+ `Domains: ${(Array.isArray(siteData?.domains) ? siteData.domains.join(', ') : '') || 'n/a'}`,
1418
+ `Page name: ${pageData?.name || pageId || 'n/a'}`,
1419
+ `Page slug/id: ${pageId || 'n/a'}`,
1420
+ `Existing meta title: ${pageData?.metaTitle || ''}`,
1421
+ `Existing meta description: ${pageData?.metaDescription || ''}`,
1422
+ `Existing structured data: ${pageData?.structuredData || ''}`,
1423
+ '',
1424
+ 'Structured data templates (keep keys; fill in values):',
1425
+ `Page: ${pageStructuredTemplate}`,
1426
+ includeSiteStructuredData ? `Site: ${siteStructuredTemplate}` : '',
1427
+ '',
1428
+ 'Block content summary:',
1429
+ clampText(blockSummary || 'n/a', 8000),
1430
+ '',
1431
+ `Return JSON only with this shape: ${responseShape}`,
1432
+ ].filter(Boolean).join('\n')
1433
+
1434
+ const body = {
1435
+ model: OPENAI_MODEL,
1436
+ temperature: 0.3,
1437
+ response_format: { type: 'json_object' },
1438
+ messages: [
1439
+ { role: 'system', content: system },
1440
+ { role: 'user', content: user },
1441
+ ],
1442
+ }
1443
+
1444
+ const resp = await fetch('https://api.openai.com/v1/chat/completions', {
1445
+ method: 'POST',
1446
+ headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${OPENAI_API_KEY}` },
1447
+ body: JSON.stringify(body),
1448
+ })
1449
+
1450
+ if (!resp.ok) {
1451
+ const txt = await resp.text().catch(() => '')
1452
+ throw new Error(`OpenAI error ${resp.status}: ${txt}`)
1453
+ }
1454
+
1455
+ const json = await resp.json()
1456
+ const content = json?.choices?.[0]?.message?.content || '{}'
1457
+ try {
1458
+ return JSON.parse(content)
1459
+ }
1460
+ catch (err) {
1461
+ logger.error('Failed to parse OpenAI response', err)
1462
+ return {}
1463
+ }
1464
+ }
1465
+
1466
+ const buildFieldsList = (pagesSnap, siteData = {}) => {
1467
+ const descriptors = []
1468
+ const descriptorMap = new Map()
1469
+
1470
+ const siteMetaTargets = [
1471
+ ['metaTitle', 'text', 'Site Meta Title'],
1472
+ ['metaDescription', 'text', 'Site Meta Description'],
1473
+ ['structuredData', 'json', 'Site Structured Data (JSON-LD)'],
1474
+ ]
1475
+ for (const [field, type, title] of siteMetaTargets) {
1476
+ const path = `site.meta.${field}`
1477
+ const descriptor = {
1478
+ path,
1479
+ pageId: null,
1480
+ pageName: siteData?.name || 'Site',
1481
+ location: 'siteMeta',
1482
+ blockIndex: -1,
1483
+ blockId: 'meta',
1484
+ field,
1485
+ type,
1486
+ title,
1487
+ option: null,
1488
+ schema: null,
1489
+ }
1490
+ descriptors.push(descriptor)
1491
+ descriptorMap.set(path, descriptor)
1492
+ }
1493
+
1494
+ for (const pageDoc of pagesSnap.docs) {
1495
+ const pageId = pageDoc.id
1496
+ const pageData = pageDoc.data() || {}
1497
+ const pageName = pageData.name || pageId
1498
+ const metaTargets = [
1499
+ ['metaTitle', 'text', 'Meta Title'],
1500
+ ['metaDescription', 'text', 'Meta Description'],
1501
+ ['structuredData', 'json', 'Structured Data (JSON-LD)'],
1502
+ ]
1503
+ for (const [field, type, title] of metaTargets) {
1504
+ const path = `${pageId}.meta.${field}`
1505
+ const descriptor = {
1506
+ path,
1507
+ pageId,
1508
+ pageName,
1509
+ location: 'pageMeta',
1510
+ blockIndex: -1,
1511
+ blockId: 'meta',
1512
+ field,
1513
+ type,
1514
+ title,
1515
+ option: null,
1516
+ schema: null,
1517
+ }
1518
+ descriptors.push(descriptor)
1519
+ descriptorMap.set(path, descriptor)
1520
+ }
1521
+
1522
+ const locations = [
1523
+ ['content', Array.isArray(pageData.content) ? pageData.content : []],
1524
+ ['postContent', Array.isArray(pageData.postContent) ? pageData.postContent : []],
1525
+ ]
1526
+
1527
+ for (const [location, blocks] of locations) {
1528
+ blocks.forEach((block, blockIndex) => {
1529
+ const meta = block?.meta || {}
1530
+ const values = block?.values || {}
1531
+ const blockId = block?.blockId || `block-${blockIndex}`
1532
+ for (const [field, cfg] of Object.entries(meta)) {
1533
+ if (!isFillableMeta(cfg))
1534
+ continue
1535
+ const type = cfg.type || 'text'
1536
+ const path = `${pageId}.${location}.${blockId}.${field}`
1537
+ const descriptor = {
1538
+ path,
1539
+ pageId,
1540
+ pageName,
1541
+ location,
1542
+ blockIndex,
1543
+ blockId,
1544
+ field,
1545
+ type,
1546
+ title: cfg.title || field,
1547
+ option: cfg.option || null,
1548
+ schema: Array.isArray(cfg.schema) ? cfg.schema : null,
1549
+ }
1550
+ descriptors.push(descriptor)
1551
+ descriptorMap.set(path, descriptor)
1552
+ }
1553
+ })
1554
+ }
1555
+ }
1556
+
1557
+ return { descriptors, descriptorMap }
1558
+ }
1559
+
1560
+ const formatFieldPrompt = (descriptor) => {
1561
+ const parts = [
1562
+ `- path: ${descriptor.path}`,
1563
+ ` page: ${descriptor.pageName}`,
1564
+ ` field: ${descriptor.title || descriptor.field}`,
1565
+ ` type: ${descriptor.type}`,
1566
+ ]
1567
+ if (descriptor.option?.options?.length) {
1568
+ const opts = descriptor.option.options
1569
+ .map(opt => `${opt?.[descriptor.option.optionsValue || 'value']} (${opt?.[descriptor.option.optionsKey || 'title'] || ''})`)
1570
+ .join(', ')
1571
+ parts.push(` options: ${opts}`)
1572
+ }
1573
+ if (descriptor.schema?.length) {
1574
+ const schemaFields = descriptor.schema.map(s => `${s.field}:${s.type}`).join(', ')
1575
+ parts.push(` array schema: ${schemaFields}`)
1576
+ }
1577
+ return parts.join('\n')
1578
+ }
1579
+
1580
+ const summarizeAgentMeta = (meta = {}) => {
1581
+ const entries = []
1582
+ for (const [key, val] of Object.entries(meta)) {
1583
+ if (val == null)
1584
+ continue
1585
+ const str = typeof val === 'object' ? JSON.stringify(val) : String(val)
1586
+ if (!str.trim())
1587
+ continue
1588
+ // Trim extremely long fields to avoid prompt bloat
1589
+ const trimmed = str.length > 400 ? `${str.slice(0, 400)}...` : str
1590
+ entries.push(`${key}: ${trimmed}`)
1591
+ }
1592
+ return entries
1593
+ }
1594
+
1595
+ const summarizeAgentRoot = (agent = {}) => {
1596
+ const entries = []
1597
+ for (const [key, val] of Object.entries(agent)) {
1598
+ if (key === 'meta' || key === 'userId' || key === 'uid')
1599
+ continue
1600
+ if (val == null)
1601
+ continue
1602
+ const str = typeof val === 'object' ? JSON.stringify(val) : String(val)
1603
+ if (!str.trim())
1604
+ continue
1605
+ const trimmed = str.length > 400 ? `${str.slice(0, 400)}...` : str
1606
+ entries.push(`${key}: ${trimmed}`)
1607
+ }
1608
+ return entries
1609
+ }
1610
+
1611
+ const callOpenAiForSiteBootstrap = async ({ siteData, agentData, instructions, fields }) => {
1612
+ if (!OPENAI_API_KEY)
1613
+ throw new Error('OPENAI_API_KEY not set')
1614
+ if (!fields || fields.length === 0)
1615
+ return {}
1616
+
1617
+ const siteSummary = [
1618
+ `Site name: ${siteData?.name || 'n/a'}`,
1619
+ `Domains: ${(Array.isArray(siteData?.domains) ? siteData.domains.join(', ') : '') || 'n/a'}`,
1620
+ ].join('\n')
1621
+
1622
+ const rootLines = agentData ? summarizeAgentRoot(agentData) : []
1623
+ const metaLines = agentData ? summarizeAgentMeta(agentData.meta || {}) : []
1624
+ const agentSummary = agentData
1625
+ ? [
1626
+ `Agent name: ${agentData.meta?.name || agentData.name || agentData.userId || ''}`,
1627
+ `Title: ${agentData.meta?.title || ''}`,
1628
+ `Bio: ${agentData.meta?.bio || ''}`,
1629
+ `Phone: ${agentData.meta?.phone || ''}`,
1630
+ `Email: ${agentData.meta?.email || ''}`,
1631
+ rootLines.length ? 'Additional agent fields:' : '',
1632
+ ...rootLines,
1633
+ metaLines.length ? 'Additional agent meta:' : '',
1634
+ ...metaLines,
1635
+ ].filter(Boolean).join('\n')
1636
+ : 'Agent data: n/a'
1637
+
1638
+ const fieldPrompts = fields.map(formatFieldPrompt).join('\n')
1639
+ const structuredDataInstructions = [
1640
+ 'Structured data templates (keep keys; fill in values):',
1641
+ `Site: ${SITE_STRUCTURED_DATA_TEMPLATE}`,
1642
+ `Page: ${PAGE_STRUCTURED_DATA_TEMPLATE}`,
1643
+ ].join('\n')
1644
+
1645
+ const system = [
1646
+ 'You are a website copywriter tasked with pre-filling CMS blocks for a brand-new site.',
1647
+ 'Use the provided site/agent context and instructions.',
1648
+ 'Keep outputs concise, professional, and free of placeholder words like "lorem ipsum".',
1649
+ 'Return JSON only, with this shape: {"fields": {"<path>": <value>}}.',
1650
+ 'For text/richtext/textarea: short, readable copy. For numbers: numeric only.',
1651
+ 'For arrays without schema: array of short strings. For arrays with schema: array of objects matching the schema fields.',
1652
+ 'For option fields: return one of the allowed option values (not the label).',
1653
+ 'If you truly cannot infer a value, return an empty string for that key.',
1654
+ 'For structuredData fields: return a JSON object matching the provided template shape.',
1655
+ 'Preserve CMS tokens like {{cms-site}}, {{cms-url}}, and {{cms-logo}} exactly as-is.',
1656
+ 'All content, including meta titles/descriptions and structured data, should be optimized for maximum SEO performance.',
1657
+ ].join(' ')
1658
+
1659
+ const user = [
1660
+ siteSummary,
1661
+ `AI instructions: ${instructions || 'n/a'}`,
1662
+ agentSummary,
1663
+ '',
1664
+ structuredDataInstructions,
1665
+ '',
1666
+ 'Fields to fill:',
1667
+ fieldPrompts,
1668
+ ].join('\n')
1669
+
1670
+ const body = {
1671
+ model: OPENAI_MODEL,
1672
+ temperature: 0.4,
1673
+ response_format: { type: 'json_object' },
1674
+ messages: [
1675
+ { role: 'system', content: system },
1676
+ { role: 'user', content: user },
1677
+ ],
1678
+ }
1679
+
1680
+ const resp = await fetch('https://api.openai.com/v1/chat/completions', {
1681
+ method: 'POST',
1682
+ headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${OPENAI_API_KEY}` },
1683
+ body: JSON.stringify(body),
1684
+ })
1685
+
1686
+ if (!resp.ok) {
1687
+ const txt = await resp.text().catch(() => '')
1688
+ throw new Error(`OpenAI error ${resp.status}: ${txt}`)
1689
+ }
1690
+
1691
+ const json = await resp.json()
1692
+ const content = json?.choices?.[0]?.message?.content || '{}'
1693
+ try {
1694
+ return JSON.parse(content)
1695
+ }
1696
+ catch (err) {
1697
+ logger.error('Failed to parse OpenAI response', err)
1698
+ return {}
1699
+ }
1700
+ }
1701
+
1702
+ const applyAiResults = (descriptorMap, pagesSnap, aiResults, siteData = {}) => {
1703
+ if (!aiResults || typeof aiResults.fields !== 'object')
1704
+ return { pageUpdates: {}, siteUpdates: {} }
1705
+
1706
+ const pageUpdates = {}
1707
+ const siteUpdates = {}
1708
+ const pageDocsMap = new Map()
1709
+ for (const doc of pagesSnap.docs)
1710
+ pageDocsMap.set(doc.id, doc.data() || {})
1711
+
1712
+ for (const [path, value] of Object.entries(aiResults.fields)) {
1713
+ const descriptor = descriptorMap.get(path)
1714
+ if (!descriptor)
1715
+ continue
1716
+
1717
+ const sanitized = sanitizeValueForMeta(descriptor.type, value, { option: descriptor.option, schema: descriptor.schema })
1718
+ if (sanitized === null || sanitized === undefined)
1719
+ continue
1720
+ if (Array.isArray(sanitized) && sanitized.length === 0)
1721
+ continue
1722
+ if (typeof sanitized === 'string' && sanitized.trim().length === 0)
1723
+ continue
1724
+
1725
+ if (descriptor.location === 'siteMeta') {
1726
+ if (descriptor.field === 'structuredData')
1727
+ siteUpdates.structuredData = sanitized
1728
+ else if (descriptor.field === 'metaTitle')
1729
+ siteUpdates.metaTitle = sanitized
1730
+ else if (descriptor.field === 'metaDescription')
1731
+ siteUpdates.metaDescription = sanitized
1732
+ continue
1733
+ }
1734
+
1735
+ const pageData = pageDocsMap.get(descriptor.pageId) || {}
1736
+ if (!pageUpdates[descriptor.pageId]) {
1737
+ pageUpdates[descriptor.pageId] = {
1738
+ content: Array.isArray(pageData.content) ? JSON.parse(JSON.stringify(pageData.content)) : [],
1739
+ postContent: Array.isArray(pageData.postContent) ? JSON.parse(JSON.stringify(pageData.postContent)) : [],
1740
+ metaTitle: pageData.metaTitle || '',
1741
+ metaDescription: pageData.metaDescription || '',
1742
+ structuredData: pageData.structuredData || '',
1743
+ }
1744
+ }
1745
+
1746
+ if (descriptor.location === 'pageMeta') {
1747
+ if (descriptor.field === 'metaTitle')
1748
+ pageUpdates[descriptor.pageId].metaTitle = sanitized
1749
+ else if (descriptor.field === 'metaDescription')
1750
+ pageUpdates[descriptor.pageId].metaDescription = sanitized
1751
+ else if (descriptor.field === 'structuredData')
1752
+ pageUpdates[descriptor.pageId].structuredData = sanitized
1753
+ continue
1754
+ }
1755
+
1756
+ const targetBlocks = descriptor.location === 'postContent' ? pageUpdates[descriptor.pageId].postContent : pageUpdates[descriptor.pageId].content
1757
+ const block = targetBlocks[descriptor.blockIndex]
1758
+ if (!block)
1759
+ continue
1760
+ block.values = block.values || {}
1761
+ block.values[descriptor.field] = sanitized
1762
+ }
1763
+
1764
+ return { pageUpdates, siteUpdates }
1765
+ }
1766
+
1767
+ const stripCodeFences = (text) => {
1768
+ if (!text)
1769
+ return ''
1770
+ return text
1771
+ .replace(/```json\s*/gi, '')
1772
+ .replace(/```\s*/g, '')
1773
+ .trim()
1774
+ }
1775
+
1776
+ const extractJsonFromText = (text) => {
1777
+ const cleaned = stripCodeFences(text)
1778
+ const firstBrace = cleaned.indexOf('{')
1779
+ const lastBrace = cleaned.lastIndexOf('}')
1780
+ if (firstBrace === -1 || lastBrace === -1 || lastBrace <= firstBrace)
1781
+ return null
1782
+ const candidate = cleaned.slice(firstBrace, lastBrace + 1)
1783
+ try {
1784
+ return JSON.parse(candidate)
1785
+ }
1786
+ catch {
1787
+ return null
1788
+ }
1789
+ }
1790
+
1791
+ const parseAiJson = (text) => {
1792
+ if (!text)
1793
+ return null
1794
+ try {
1795
+ return JSON.parse(stripCodeFences(text))
1796
+ }
1797
+ catch {
1798
+ return extractJsonFromText(text)
1799
+ }
1800
+ }
1801
+
1802
+ const buildBlockAiPrompt = ({
1803
+ blockId,
1804
+ blockName,
1805
+ content,
1806
+ fields,
1807
+ currentValues,
1808
+ meta,
1809
+ instructions,
1810
+ }) => {
1811
+ const fieldLines = fields
1812
+ .map(field => `- ${field.id} (${field.type || 'text'}): ${field.label || ''}`)
1813
+ .join('\n')
1814
+
1815
+ return [
1816
+ `Block ID: ${blockId}`,
1817
+ `Block Name: ${blockName || 'n/a'}`,
1818
+ '',
1819
+ 'Selected fields:',
1820
+ fieldLines || '- none',
1821
+ '',
1822
+ 'Block content (reference only):',
1823
+ content || 'n/a',
1824
+ '',
1825
+ 'Field metadata (JSON):',
1826
+ JSON.stringify(meta || {}),
1827
+ '',
1828
+ 'Current field values (JSON):',
1829
+ JSON.stringify(currentValues || {}),
1830
+ '',
1831
+ `Instructions: ${instructions || 'n/a'}`,
1832
+ '',
1833
+ 'Return ONLY valid JSON.',
1834
+ 'The response should be a JSON object where keys are the selected field ids.',
1835
+ 'You must return values for every selected field. Do not omit any field.',
1836
+ 'If unsure, make a best-guess value instead of leaving it blank.',
1837
+ 'For richtext, return HTML strings. For textarea, return plain text.',
1838
+ 'For arrays, return an array that matches the schema when possible.',
1839
+ ].join('\n')
1840
+ }
1841
+
1842
+ exports.updateSeoFromAi = onCall({ timeoutSeconds: 180 }, async (request) => {
1843
+ const data = request.data || {}
1844
+ const auth = request.auth
1845
+ const { orgId, siteId, pageId, uid } = data
1846
+ if (!auth?.uid || auth.uid !== uid)
1847
+ throw new HttpsError('permission-denied', 'Unauthorized')
1848
+ if (!orgId || !siteId || !pageId)
1849
+ throw new HttpsError('invalid-argument', 'Missing orgId, siteId, or pageId')
1850
+ const allowed = await permissionCheck(auth.uid, 'write', `organizations/${orgId}/sites/${siteId}/pages`)
1851
+ if (!allowed)
1852
+ throw new HttpsError('permission-denied', 'Not allowed to update page SEO')
1853
+
1854
+ const pageRef = db.collection('organizations').doc(orgId).collection('sites').doc(siteId).collection('pages').doc(pageId)
1855
+ const siteRef = db.collection('organizations').doc(orgId).collection('sites').doc(siteId)
1856
+ const [pageSnap, siteSnap] = await Promise.all([pageRef.get(), siteRef.get()])
1857
+ if (!pageSnap.exists)
1858
+ throw new HttpsError('not-found', 'Page not found')
1859
+ const pageData = pageSnap.data() || {}
1860
+ const siteData = siteSnap.exists ? (siteSnap.data() || {}) : {}
1861
+
1862
+ const blockSummary = [
1863
+ 'Index blocks:',
1864
+ summarizeBlocksForSeo(pageData.content),
1865
+ '',
1866
+ 'Post blocks:',
1867
+ summarizeBlocksForSeo(pageData.postContent),
1868
+ ].filter(Boolean).join('\n')
1869
+
1870
+ const includeSiteStructuredData = shouldUpdateSiteStructuredData(siteData)
1871
+ const aiResults = await callOpenAiForPageSeo({
1872
+ siteData,
1873
+ pageData,
1874
+ pageId,
1875
+ blockSummary,
1876
+ includeSiteStructuredData,
1877
+ })
1878
+
1879
+ const pageUpdates = {}
1880
+ const metaTitle = sanitizeValueForMeta('text', aiResults?.metaTitle)
1881
+ const metaDescription = sanitizeValueForMeta('text', aiResults?.metaDescription)
1882
+ const structuredData = sanitizeValueForMeta('json', aiResults?.structuredData)
1883
+ if (metaTitle)
1884
+ pageUpdates.metaTitle = metaTitle
1885
+ if (metaDescription)
1886
+ pageUpdates.metaDescription = metaDescription
1887
+ if (structuredData)
1888
+ pageUpdates.structuredData = structuredData
1889
+
1890
+ const siteUpdates = {}
1891
+ if (includeSiteStructuredData) {
1892
+ const siteStructuredData = sanitizeValueForMeta('json', aiResults?.siteStructuredData)
1893
+ if (siteStructuredData)
1894
+ siteUpdates.structuredData = siteStructuredData
1895
+ }
1896
+
1897
+ if (Object.keys(pageUpdates).length > 0)
1898
+ await pageRef.set(pageUpdates, { merge: true })
1899
+ if (includeSiteStructuredData && Object.keys(siteUpdates).length > 0)
1900
+ await siteRef.set(siteUpdates, { merge: true })
1901
+
1902
+ return {
1903
+ pageId,
1904
+ metaTitle: pageUpdates.metaTitle || '',
1905
+ metaDescription: pageUpdates.metaDescription || '',
1906
+ structuredData: pageUpdates.structuredData || '',
1907
+ siteStructuredDataUpdated: includeSiteStructuredData && !!siteUpdates.structuredData,
1908
+ siteStructuredData: siteUpdates.structuredData || '',
1909
+ }
1910
+ })
1911
+
1912
+ exports.getCloudflarePagesProject = onCall(async (request) => {
1913
+ if (!request?.auth) {
1914
+ throw new HttpsError('unauthenticated', 'Authentication required.')
1915
+ }
1916
+
1917
+ if (!CLOUDFLARE_PAGES_PROJECT) {
1918
+ logger.warn('CLOUDFLARE_PAGES_PROJECT is not set.')
1919
+ return { project: '' }
1920
+ }
1921
+
1922
+ return { project: CLOUDFLARE_PAGES_PROJECT }
1923
+ })
1924
+
1925
+ exports.generateBlockFields = onCall({ timeoutSeconds: 180 }, async (request) => {
1926
+ const data = request.data || {}
1927
+ const auth = request.auth
1928
+ const { orgId, uid, blockId, blockName, content, fields, currentValues, meta, instructions } = data
1929
+
1930
+ if (!auth?.uid || auth.uid !== uid)
1931
+ throw new HttpsError('permission-denied', 'Unauthorized')
1932
+ if (!orgId || !blockId)
1933
+ throw new HttpsError('invalid-argument', 'Missing orgId or blockId')
1934
+ if (!Array.isArray(fields) || fields.length === 0)
1935
+ throw new HttpsError('invalid-argument', 'No fields selected')
1936
+ if (!OPENAI_API_KEY)
1937
+ throw new HttpsError('failed-precondition', 'OPENAI_API_KEY not set')
1938
+
1939
+ const allowed = await permissionCheck(auth.uid, 'write', `organizations/${orgId}/blocks`)
1940
+ if (!allowed)
1941
+ throw new HttpsError('permission-denied', 'Not allowed to update blocks')
1942
+
1943
+ const filteredFields = fields.filter(field => field.type !== 'image'
1944
+ && field.type !== 'color'
1945
+ && !/url/i.test(field.id)
1946
+ && !/color/i.test(field.id))
1947
+ if (filteredFields.length === 0)
1948
+ throw new HttpsError('invalid-argument', 'No eligible fields selected')
1949
+
1950
+ const systemPrompt = 'You are a helpful assistant that writes content for CMS block fields.'
1951
+ const userPrompt = buildBlockAiPrompt({
1952
+ blockId,
1953
+ blockName,
1954
+ content,
1955
+ fields: filteredFields,
1956
+ currentValues,
1957
+ meta,
1958
+ instructions,
1959
+ })
1960
+
1961
+ const response = await fetch('https://api.openai.com/v1/chat/completions', {
1962
+ method: 'POST',
1963
+ headers: {
1964
+ 'Content-Type': 'application/json',
1965
+ 'Authorization': `Bearer ${OPENAI_API_KEY}`,
1966
+ },
1967
+ body: JSON.stringify({
1968
+ model: OPENAI_MODEL,
1969
+ temperature: 0.6,
1970
+ messages: [
1971
+ { role: 'system', content: systemPrompt },
1972
+ { role: 'user', content: userPrompt },
1973
+ ],
1974
+ }),
1975
+ })
1976
+
1977
+ if (!response.ok) {
1978
+ const text = await response.text().catch(() => '')
1979
+ throw new HttpsError('internal', `OpenAI error ${response.status}: ${text}`)
1980
+ }
1981
+
1982
+ const json = await response.json()
1983
+ const contentText = json?.choices?.[0]?.message?.content || ''
1984
+ const parsed = parseAiJson(contentText)
1985
+ if (!parsed || typeof parsed !== 'object') {
1986
+ logger.error('AI response parse failed', { contentText })
1987
+ throw new HttpsError('internal', 'Failed to parse AI response')
1988
+ }
1989
+
1990
+ const allowedFields = new Set(filteredFields.map(field => field.id))
1991
+ const filtered = {}
1992
+ Object.keys(parsed).forEach((key) => {
1993
+ if (allowedFields.has(key))
1994
+ filtered[key] = parsed[key]
1995
+ })
1996
+
1997
+ return {
1998
+ fields: filtered,
1999
+ }
2000
+ })
2001
+
2002
+ exports.siteAiBootstrapEnqueue = onDocumentCreated(
2003
+ { document: 'organizations/{orgId}/sites/{siteId}', timeoutSeconds: 180 },
2004
+ async (event) => {
2005
+ const { orgId, siteId } = event.params
2006
+ if (!orgId || !siteId || siteId === 'templates')
2007
+ return
2008
+ const data = event.data?.data() || {}
2009
+ if (!data.aiAgentUserId && !data.aiInstructions)
2010
+ return
2011
+ const siteRef = db.collection('organizations').doc(orgId).collection('sites').doc(siteId)
2012
+ await siteRef.set({ aiBootstrapStatus: 'queued' }, { merge: true })
2013
+ await pubsub.topic(SITE_AI_TOPIC).publishMessage({ json: { orgId, siteId, attempt: 0 } })
2014
+ logger.info('Enqueued AI bootstrap for site', { orgId, siteId })
2015
+ },
2016
+ )
2017
+
2018
+ exports.syncUserMetaFromPublishedSiteSettings = onDocumentWritten(
2019
+ { document: 'organizations/{orgId}/published-site-settings/{siteId}', timeoutSeconds: 180 },
2020
+ async (event) => {
2021
+ const change = event.data
2022
+ if (!change?.after?.exists)
2023
+ return
2024
+
2025
+ const siteData = change.after.data() || {}
2026
+ const users = Array.isArray(siteData.users) ? siteData.users : []
2027
+ const primaryUser = users[0]
2028
+ if (!primaryUser)
2029
+ return
2030
+
2031
+ const userRef = await resolveStagedUserRef(primaryUser)
2032
+ if (!userRef) {
2033
+ logger.log('syncUserMetaFromPublishedSiteSettings: no staged user found', { primaryUser })
2034
+ return
2035
+ }
2036
+
2037
+ const userSnap = await userRef.get()
2038
+ const userData = userSnap.data() || {}
2039
+ const currentMeta = userData.meta || {}
2040
+ const targetMeta = pickSyncFields(siteData)
2041
+ const metaDiff = buildUpdateDiff(currentMeta, targetMeta)
2042
+ if (!Object.keys(metaDiff).length)
2043
+ return
2044
+
2045
+ const updatePayload = {}
2046
+ for (const [key, value] of Object.entries(metaDiff)) {
2047
+ updatePayload[`meta.${key}`] = value
2048
+ }
2049
+
2050
+ await userRef.update(updatePayload)
2051
+ logger.log('syncUserMetaFromPublishedSiteSettings: updated user meta', {
2052
+ siteId: event.params.siteId,
2053
+ orgId: event.params.orgId,
2054
+ userId: userRef.id,
2055
+ fields: Object.keys(updatePayload),
2056
+ })
2057
+ },
2058
+ )
2059
+
2060
+ exports.ensurePublishedSiteDomains = onDocumentWritten(
2061
+ { document: 'organizations/{orgId}/published-site-settings/{siteId}', timeoutSeconds: 180 },
2062
+ async (event) => {
2063
+ const change = event.data
2064
+ if (!change?.after?.exists)
2065
+ return
2066
+
2067
+ const orgId = event.params.orgId
2068
+ const siteId = event.params.siteId
2069
+ const siteRef = change.after.ref
2070
+ const siteData = change.after.data() || {}
2071
+ const beforeData = change.before?.data?.() || {}
2072
+ const domainErrorChanged = beforeData?.domainError !== siteData?.domainError
2073
+ const rawDomains = Array.isArray(siteData.domains) ? siteData.domains : []
2074
+ const normalizedDomains = Array.from(new Set(rawDomains.map(normalizeDomain).filter(Boolean)))
2075
+ const beforeRawDomains = Array.isArray(beforeData.domains) ? beforeData.domains : []
2076
+ const beforeNormalizedDomains = Array.from(new Set(beforeRawDomains.map(normalizeDomain).filter(Boolean)))
2077
+
2078
+ const removedDomains = beforeNormalizedDomains.filter(domain => !normalizedDomains.includes(domain))
2079
+ const removedOwnedDomains = []
2080
+ for (const domain of removedDomains) {
2081
+ const registryRef = db.collection(DOMAIN_REGISTRY_COLLECTION).doc(domain)
2082
+ const registrySnap = await registryRef.get()
2083
+ if (!registrySnap.exists)
2084
+ continue
2085
+ const registryData = registrySnap.data() || {}
2086
+ if (registryData.sitePath === siteRef.path) {
2087
+ await registryRef.delete()
2088
+ removedOwnedDomains.push(domain)
2089
+ }
2090
+ }
2091
+
2092
+ const conflictDomains = []
2093
+ for (const domain of normalizedDomains) {
2094
+ const registryRef = db.collection(DOMAIN_REGISTRY_COLLECTION).doc(domain)
2095
+ let conflict = false
2096
+
2097
+ await db.runTransaction(async (transaction) => {
2098
+ const registrySnap = await transaction.get(registryRef)
2099
+ if (!registrySnap.exists) {
2100
+ transaction.set(registryRef, {
2101
+ domain,
2102
+ orgId,
2103
+ siteId,
2104
+ sitePath: siteRef.path,
2105
+ updatedAt: Firestore.FieldValue.serverTimestamp(),
2106
+ })
2107
+ return
2108
+ }
2109
+
2110
+ const registryData = registrySnap.data() || {}
2111
+ if (registryData.sitePath === siteRef.path) {
2112
+ transaction.set(registryRef, {
2113
+ domain,
2114
+ orgId,
2115
+ siteId,
2116
+ sitePath: siteRef.path,
2117
+ updatedAt: Firestore.FieldValue.serverTimestamp(),
2118
+ }, { merge: true })
2119
+ return
2120
+ }
2121
+
2122
+ conflict = true
2123
+ })
2124
+
2125
+ if (conflict)
2126
+ conflictDomains.push(domain)
2127
+ }
2128
+
2129
+ let filteredDomains = normalizedDomains
2130
+ if (conflictDomains.length) {
2131
+ const conflictSet = new Set(conflictDomains)
2132
+ const nextRawDomains = rawDomains.filter(value => !conflictSet.has(normalizeDomain(value)))
2133
+ const conflictLabel = conflictDomains.length > 1 ? 'Domains' : 'Domain'
2134
+ const conflictSuffix = conflictDomains.length > 1 ? 'those domains' : 'that domain'
2135
+ await siteRef.set({
2136
+ domains: nextRawDomains,
2137
+ domainError: `${conflictLabel} "${conflictDomains.join(', ')}" removed because another site is already using ${conflictSuffix}.`,
2138
+ }, { merge: true })
2139
+ filteredDomains = normalizedDomains.filter(domain => !conflictSet.has(domain))
2140
+ }
2141
+
2142
+ const syncDomains = Array.from(new Set(
2143
+ filteredDomains
2144
+ .map(domain => getCloudflarePagesDomain(domain))
2145
+ .filter(domain => shouldSyncCloudflareDomain(domain)),
2146
+ ))
2147
+ const removeDomains = Array.from(new Set(
2148
+ removedOwnedDomains
2149
+ .map(domain => getCloudflarePagesDomain(domain))
2150
+ .filter(domain => shouldSyncCloudflareDomain(domain)),
2151
+ ))
2152
+ if (removeDomains.length) {
2153
+ await Promise.all(removeDomains.map(domain => removeCloudflarePagesDomain(domain, { orgId, siteId })))
2154
+ }
2155
+ if (!syncDomains.length) {
2156
+ if (!conflictDomains.length && siteData.domainError && !domainErrorChanged) {
2157
+ await siteRef.set({ domainError: Firestore.FieldValue.delete() }, { merge: true })
2158
+ }
2159
+ return
2160
+ }
2161
+
2162
+ const results = await Promise.all(syncDomains.map(domain => addCloudflarePagesDomain(domain, { orgId, siteId })))
2163
+ const failed = results
2164
+ .map((result, index) => ({ result, domain: syncDomains[index] }))
2165
+ .filter(item => !item.result?.ok)
2166
+
2167
+ if (!failed.length) {
2168
+ if (!conflictDomains.length && siteData.domainError && !domainErrorChanged) {
2169
+ await siteRef.set({ domainError: Firestore.FieldValue.delete() }, { merge: true })
2170
+ }
2171
+ return
2172
+ }
2173
+
2174
+ const errorDomains = failed.map(item => item.domain)
2175
+ const errorDetails = failed
2176
+ .map(item => item.result?.error)
2177
+ .filter(Boolean)
2178
+ .join('; ')
2179
+ const cloudflareMessage = `Cloudflare domain sync failed for "${errorDomains.join(', ')}". ${errorDetails || 'Check function logs.'}`.trim()
2180
+ const combinedMessage = conflictDomains.length
2181
+ ? `${cloudflareMessage} Conflicts detected for "${conflictDomains.join(', ')}".`
2182
+ : cloudflareMessage
2183
+ if (siteData.domainError !== combinedMessage) {
2184
+ await siteRef.set({ domainError: combinedMessage }, { merge: true })
2185
+ }
2186
+ },
2187
+ )
2188
+
2189
+ exports.syncSiteSettingsFromUserMeta = onDocumentWritten(
2190
+ { document: 'staged-users/{stagedId}', timeoutSeconds: 180 },
2191
+ async (event) => {
2192
+ const change = event.data
2193
+ if (!change?.after?.exists)
2194
+ return
2195
+
2196
+ const beforeMeta = (change.before.data() || {}).meta || {}
2197
+ const afterMeta = (change.after.data() || {}).meta || {}
2198
+ const metaDiff = buildUpdateDiff(pickSyncFields(beforeMeta), pickSyncFields(afterMeta))
2199
+ if (!Object.keys(metaDiff).length)
2200
+ return
2201
+
2202
+ const stagedId = event.params.stagedId
2203
+ const authUserId = change.after.data()?.userId
2204
+ const userIds = Array.from(new Set([stagedId, authUserId].filter(Boolean)))
2205
+ if (!userIds.length)
2206
+ return
2207
+
2208
+ const matchedSites = new Map()
2209
+ for (const userId of userIds) {
2210
+ const snap = await db.collectionGroup('sites')
2211
+ .where('users', 'array-contains', userId)
2212
+ .get()
2213
+
2214
+ if (snap.empty)
2215
+ continue
2216
+
2217
+ for (const doc of snap.docs) {
2218
+ matchedSites.set(doc.ref.path, { doc, userId })
2219
+ }
2220
+ }
2221
+
2222
+ if (!matchedSites.size)
2223
+ return
2224
+
2225
+ for (const { doc, userId } of matchedSites.values()) {
2226
+ const siteData = doc.data() || {}
2227
+ const users = Array.isArray(siteData.users) ? siteData.users : []
2228
+ if (!users.length || users[0] !== userId)
2229
+ continue
2230
+
2231
+ const siteUpdate = buildUpdateDiff(siteData, pickSyncFields(afterMeta))
2232
+ if (!Object.keys(siteUpdate).length)
2233
+ continue
2234
+
2235
+ await doc.ref.update(siteUpdate)
2236
+
2237
+ const orgDoc = doc.ref.parent.parent
2238
+ const orgId = orgDoc?.id
2239
+ const siteId = doc.id
2240
+ if (orgId) {
2241
+ const publishedRef = db.collection('organizations').doc(orgId).collection('published-site-settings').doc(siteId)
2242
+ const publishedSnap = await publishedRef.get()
2243
+ if (publishedSnap.exists) {
2244
+ await publishedRef.update(siteUpdate)
2245
+ }
2246
+ }
2247
+
2248
+ logger.log('syncSiteSettingsFromUserMeta: updated site settings from user meta', {
2249
+ siteId,
2250
+ orgId: orgId || '',
2251
+ userId,
2252
+ fields: Object.keys(siteUpdate),
2253
+ })
2254
+ }
2255
+ },
2256
+ )
2257
+
2258
+ const setAiStatus = async (siteRef, status) => {
2259
+ try {
2260
+ await siteRef.set({ aiBootstrapStatus: status }, { merge: true })
2261
+ }
2262
+ catch (err) {
2263
+ logger.warn('Failed to set AI status', { status, error: err?.message })
2264
+ }
2265
+ }
2266
+
2267
+ exports.siteAiBootstrapWorker = onMessagePublished(
2268
+ { topic: SITE_AI_TOPIC, retry: true, timeoutSeconds: 540, memory: '1GiB' },
2269
+ async (event) => {
2270
+ const msg = event.data?.message?.json || {}
2271
+ const { orgId, siteId } = msg
2272
+ const attempt = msg.attempt || 0
2273
+ if (!orgId || !siteId || siteId === 'templates')
2274
+ return
2275
+
2276
+ const siteRef = db.collection('organizations').doc(orgId).collection('sites').doc(siteId)
2277
+ const siteSnap = await siteRef.get()
2278
+ if (!siteSnap.exists)
2279
+ return
2280
+ const siteData = siteSnap.data() || {}
2281
+ if (!siteData.aiAgentUserId && !siteData.aiInstructions)
2282
+ return
2283
+ await setAiStatus(siteRef, 'running')
2284
+
2285
+ const pagesRef = siteRef.collection('pages')
2286
+ let pagesSnap = await pagesRef.get()
2287
+ if (pagesSnap.empty) {
2288
+ await sleep(5000)
2289
+ pagesSnap = await pagesRef.get()
2290
+ }
2291
+ if (pagesSnap.empty) {
2292
+ if (attempt < 5) {
2293
+ await pubsub.topic(SITE_AI_TOPIC).publishMessage({ json: { orgId, siteId, attempt: attempt + 1 } })
2294
+ logger.warn('No pages found yet for AI bootstrap, requeued', { orgId, siteId, attempt })
2295
+ }
2296
+ else {
2297
+ await setAiStatus(siteRef, 'failed')
2298
+ }
2299
+ return
2300
+ }
2301
+
2302
+ let agentData = null
2303
+ if (siteData.aiAgentUserId) {
2304
+ const usersRef = db.collection('organizations').doc(orgId).collection('users')
2305
+ const agentQuery = await usersRef.where('userId', '==', siteData.aiAgentUserId).limit(1).get()
2306
+ if (!agentQuery.empty) {
2307
+ agentData = agentQuery.docs[0].data()
2308
+ }
2309
+ }
2310
+
2311
+ const { descriptors, descriptorMap } = buildFieldsList(pagesSnap, siteData)
2312
+ if (!descriptors.length) {
2313
+ logger.info('No eligible fields to fill for AI bootstrap', { orgId, siteId })
2314
+ return
2315
+ }
2316
+
2317
+ let aiResults = {}
2318
+ try {
2319
+ aiResults = await callOpenAiForSiteBootstrap({
2320
+ siteData,
2321
+ agentData,
2322
+ instructions: siteData.aiInstructions,
2323
+ fields: descriptors,
2324
+ })
2325
+ }
2326
+ catch (err) {
2327
+ logger.error('AI bootstrap failed', { orgId, siteId, error: err?.message })
2328
+ await setAiStatus(siteRef, 'failed')
2329
+ return
2330
+ }
2331
+
2332
+ const { pageUpdates, siteUpdates } = applyAiResults(descriptorMap, pagesSnap, aiResults, siteData)
2333
+ const pageIds = Object.keys(pageUpdates)
2334
+ const siteFields = Object.keys(siteUpdates)
2335
+ if (!pageIds.length && !siteFields.length) {
2336
+ logger.info('AI bootstrap returned no applicable updates', { orgId, siteId })
2337
+ await setAiStatus(siteRef, 'completed')
2338
+ return
2339
+ }
2340
+
2341
+ if (siteFields.length)
2342
+ await siteRef.update(siteUpdates)
2343
+
2344
+ for (const pageId of pageIds) {
2345
+ const update = pageUpdates[pageId]
2346
+ await siteRef.collection('pages').doc(pageId).update({
2347
+ content: update.content,
2348
+ postContent: update.postContent,
2349
+ metaTitle: update.metaTitle,
2350
+ metaDescription: update.metaDescription,
2351
+ structuredData: update.structuredData,
2352
+ })
2353
+ }
2354
+
2355
+ logger.info('AI bootstrap applied', { orgId, siteId, pagesUpdated: pageIds.length, siteUpdated: siteFields.length > 0 })
2356
+ await setAiStatus(siteRef, 'completed')
2357
+ },
2358
+ )