@edgedev/firebase 2.1.79 → 2.2.80
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/package.json +1 -1
- package/src/.env.dev +25 -1
- package/src/.env.prod +25 -1
- package/src/cms.js +2358 -0
- package/src/functions.js +1 -0
- package/src/index.js +2 -1
- package/src/kv/kvClient.js +146 -0
- package/src/kv/kvMirror.js +212 -0
- package/src/package.json +40 -34
- package/src/postinstall.sh +76 -39
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, '<')
|
|
448
|
+
.replace(/>/g, '>')
|
|
449
|
+
.replace(/"/g, '"')
|
|
450
|
+
.replace(/'/g, ''')
|
|
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
|
+
)
|