@oneclick.dev/cms-core-modules 0.0.73 → 0.0.75

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.
@@ -1,1188 +0,0 @@
1
- import { createRouter, defineEventHandler, createError, getQuery } from 'h3'
2
-
3
- /**
4
- * Dependencies injected by the CMS when creating this handler.
5
- */
6
- export interface ModuleHandlerDeps {
7
- initFirebase: (event: any, integrationId: string) => Promise<any>
8
- normalizeTimestamps: (data: any) => any
9
- decrypt: (data: string) => string
10
- getGoogleAccessToken: (config: { clientEmail: string; privateKey: string; projectId: string }, scopes: string[]) => Promise<string>
11
- }
12
-
13
- const GA_DATA_API = 'https://analyticsdata.googleapis.com/v1beta'
14
- const GA_SCOPES = [
15
- 'https://www.googleapis.com/auth/analytics.readonly',
16
- 'https://www.googleapis.com/auth/webmasters.readonly',
17
- ]
18
- const SEARCH_CONSOLE_API = 'https://searchconsole.googleapis.com/webmasters/v3'
19
-
20
- /**
21
- * Google Analytics module server handler factory.
22
- *
23
- * Routes (relative to `/api/v1/modules/:instanceId/`):
24
- * GET /report → overview report with period comparison
25
- * GET /realtime → real-time active users + pages + sources
26
- * GET /top-pages → top pages by views
27
- * GET /top-sources → top traffic sources
28
- * GET /devices → device category breakdown
29
- * GET /countries → top countries
30
- * GET /acquisition/channels → channel grouping breakdown
31
- * GET /acquisition/campaigns → campaign performance
32
- * GET /acquisition/referrals → referral sources with landing pages
33
- * GET /content/all-pages → all pages with deep metrics
34
- * GET /content/landing-pages → top landing (entry) pages
35
- * GET /content/exit-pages → top exit pages
36
- * GET /audience/overview → new vs returning, engagement rate
37
- * GET /audience/technology → browser + OS breakdown
38
- * GET /audience/languages → language breakdown
39
- * GET /audience/hours → sessions by hour-of-day (peak hours)
40
- */
41
- export function createServerHandler(deps: ModuleHandlerDeps) {
42
- const { decrypt, getGoogleAccessToken } = deps
43
- const router = createRouter()
44
-
45
- /**
46
- * Resolve the Google Service Account credentials + property ID from the module config.
47
- */
48
- async function getGAContext(event: any) {
49
- const { supabase, instanceId } = event.context.module
50
-
51
- const { data: moduleRow, error } = await supabase
52
- .from('project_modules')
53
- .select('config')
54
- .eq('id', instanceId)
55
- .single()
56
-
57
- if (error || !moduleRow?.config) {
58
- throw createError({ statusCode: 500, statusMessage: 'Failed to load module config.' })
59
- }
60
-
61
- const config = moduleRow.config as Record<string, any>
62
- const propertyId = config.propertyId as string
63
- const integrationId = config.serviceAccount as string
64
- const siteUrl = (config.siteUrl as string) || ''
65
-
66
- if (!integrationId) {
67
- throw createError({ statusCode: 400, statusMessage: 'No Google Service Account configured for this module.' })
68
- }
69
- if (!propertyId) {
70
- throw createError({ statusCode: 400, statusMessage: 'No GA4 Property ID configured for this module.' })
71
- }
72
-
73
- const { data: integration, error: intError } = await supabase
74
- .from('integrations')
75
- .select('config')
76
- .eq('id', integrationId)
77
- .single()
78
-
79
- let integrationConfig = integration?.config
80
- if (intError || !integrationConfig) {
81
- const { data: agencyInt, error: agencyErr } = await supabase
82
- .from('agency_integrations')
83
- .select('config')
84
- .eq('id', integrationId)
85
- .single()
86
-
87
- if (agencyErr || !agencyInt?.config) {
88
- throw createError({ statusCode: 500, statusMessage: 'Failed to load Google Service Account credentials.' })
89
- }
90
- integrationConfig = agencyInt.config
91
- }
92
-
93
- const clientEmail = decrypt(integrationConfig.clientEmail)
94
- const privateKey = decrypt(integrationConfig.privateKey)
95
- const projectId = decrypt(integrationConfig.projectId)
96
-
97
- const accessToken = await getGoogleAccessToken(
98
- { clientEmail, privateKey, projectId },
99
- GA_SCOPES,
100
- )
101
-
102
- return { accessToken, propertyId, siteUrl }
103
- }
104
-
105
- /**
106
- * Call the GA4 Data API's runReport endpoint.
107
- */
108
- async function runReport(accessToken: string, propertyId: string, body: Record<string, any>) {
109
- const res = await fetch(`${GA_DATA_API}/properties/${propertyId}:runReport`, {
110
- method: 'POST',
111
- headers: {
112
- Authorization: `Bearer ${accessToken}`,
113
- 'Content-Type': 'application/json',
114
- },
115
- body: JSON.stringify(body),
116
- })
117
-
118
- if (!res.ok) {
119
- const err = await res.json().catch(() => ({}))
120
- console.error('GA4 runReport failed:', err)
121
- throw createError({
122
- statusCode: res.status,
123
- statusMessage: err?.error?.message || 'GA4 API request failed',
124
- })
125
- }
126
-
127
- return res.json()
128
- }
129
-
130
- // ═══════════════════════════════════════════════════════
131
- // OVERVIEW ROUTES
132
- // ═══════════════════════════════════════════════════════
133
-
134
- // ── GET /report ──────────────────────────────────────
135
- // Returns current period + previous period data for % change comparison
136
- router.get(
137
- '/report',
138
- defineEventHandler(async (event) => {
139
- const { accessToken, propertyId } = await getGAContext(event)
140
- const query = getQuery(event)
141
- const startDate = (query.startDate as string) || '30daysAgo'
142
- const endDate = (query.endDate as string) || 'today'
143
-
144
- // Calculate previous period for comparison
145
- const daysMatch = startDate.match(/^(\d+)daysAgo$/)
146
- const days = daysMatch ? parseInt(daysMatch[1], 10) : 30
147
- const prevStartDate = `${days * 2}daysAgo`
148
- const prevEndDate = `${days + 1}daysAgo`
149
-
150
- const reportMetrics = [
151
- { name: 'sessions' },
152
- { name: 'totalUsers' },
153
- { name: 'screenPageViews' },
154
- { name: 'bounceRate' },
155
- { name: 'averageSessionDuration' },
156
- { name: 'newUsers' },
157
- { name: 'engagementRate' },
158
- { name: 'sessionsPerUser' },
159
- { name: 'screenPageViewsPerSession' },
160
- ]
161
-
162
- // Try with comparison period first; fall back to current-only if it fails
163
- // (e.g. property is newer than the previous period range)
164
- let raw: any
165
- let hasComparison = true
166
- try {
167
- raw = await runReport(accessToken, propertyId, {
168
- dateRanges: [
169
- { startDate, endDate, name: 'current' },
170
- { startDate: prevStartDate, endDate: prevEndDate, name: 'previous' },
171
- ],
172
- dimensions: [{ name: 'date' }],
173
- metrics: reportMetrics,
174
- metricAggregations: ['TOTAL'],
175
- orderBys: [{ dimension: { dimensionName: 'date' } }],
176
- })
177
- } catch {
178
- hasComparison = false
179
- raw = await runReport(accessToken, propertyId, {
180
- dateRanges: [{ startDate, endDate }],
181
- dimensions: [{ name: 'date' }],
182
- metrics: reportMetrics,
183
- metricAggregations: ['TOTAL'],
184
- orderBys: [{ dimension: { dimensionName: 'date' } }],
185
- })
186
- }
187
-
188
- return transformComparisonReport(raw, hasComparison)
189
- }),
190
- )
191
-
192
- // ── GET /realtime ────────────────────────────────────
193
- router.get(
194
- '/realtime',
195
- defineEventHandler(async (event) => {
196
- const { accessToken, propertyId } = await getGAContext(event)
197
-
198
- const res = await fetch(
199
- `${GA_DATA_API}/properties/${propertyId}:runRealtimeReport`,
200
- {
201
- method: 'POST',
202
- headers: {
203
- Authorization: `Bearer ${accessToken}`,
204
- 'Content-Type': 'application/json',
205
- },
206
- body: JSON.stringify({
207
- dimensions: [{ name: 'unifiedScreenName' }],
208
- metrics: [{ name: 'activeUsers' }],
209
- orderBys: [{ metric: { metricName: 'activeUsers' }, desc: true }],
210
- limit: 5,
211
- }),
212
- },
213
- )
214
-
215
- if (!res.ok) {
216
- const err = await res.json().catch(() => ({}))
217
- throw createError({ statusCode: res.status, statusMessage: err?.error?.message || 'Realtime API failed' })
218
- }
219
-
220
- const data = await res.json()
221
- const totalActive = (data?.rows || []).reduce(
222
- (sum: number, r: any) => sum + parseInt(r.metricValues?.[0]?.value || '0', 10),
223
- 0,
224
- )
225
- const activePages = (data?.rows || []).map((r: any) => ({
226
- page: r.dimensionValues?.[0]?.value || '(not set)',
227
- activeUsers: parseInt(r.metricValues?.[0]?.value || '0', 10),
228
- }))
229
-
230
- return { activeUsers: totalActive, activePages }
231
- }),
232
- )
233
-
234
- // ── GET /top-pages ───────────────────────────────────
235
- router.get(
236
- '/top-pages',
237
- defineEventHandler(async (event) => {
238
- const { accessToken, propertyId } = await getGAContext(event)
239
- const query = getQuery(event)
240
- const startDate = (query.startDate as string) || '30daysAgo'
241
- const endDate = (query.endDate as string) || 'today'
242
- const limit = parseInt((query.limit as string) || '10', 10)
243
-
244
- const raw = await runReport(accessToken, propertyId, {
245
- dateRanges: [{ startDate, endDate }],
246
- dimensions: [{ name: 'pagePath' }],
247
- metrics: [
248
- { name: 'screenPageViews' },
249
- { name: 'totalUsers' },
250
- { name: 'averageSessionDuration' },
251
- ],
252
- orderBys: [{ metric: { metricName: 'screenPageViews' }, desc: true }],
253
- limit: limit * 2, // fetch extra to account for duplicates before aggregation
254
- })
255
-
256
- const result = transformDimensionReport(raw, 'pagePath')
257
-
258
- // Aggregate rows with the same pagePath (GA4 can split the same
259
- // page across multiple rows due to casing / trailing-slash differences)
260
- const map = new Map<string, any>()
261
- for (const row of result.rows) {
262
- const key = (row.pagePath as string).toLowerCase().replace(/\/+$/, '')
263
- const existing = map.get(key)
264
- if (existing) {
265
- existing.screenPageViews += row.screenPageViews || 0
266
- existing.totalUsers += row.totalUsers || 0
267
- // Weighted average for session duration
268
- const totalViews = existing.screenPageViews
269
- if (totalViews > 0) {
270
- existing.averageSessionDuration =
271
- ((existing.averageSessionDuration * (totalViews - (row.screenPageViews || 0)))
272
- + (row.averageSessionDuration || 0) * (row.screenPageViews || 0)) / totalViews
273
- }
274
- } else {
275
- map.set(key, { ...row })
276
- }
277
- }
278
-
279
- const aggregated = [...map.values()]
280
- .sort((a, b) => (b.screenPageViews || 0) - (a.screenPageViews || 0))
281
- .slice(0, limit)
282
-
283
- return { rows: aggregated, rowCount: result.rowCount }
284
- }),
285
- )
286
-
287
- // ── GET /top-sources ─────────────────────────────────
288
- router.get(
289
- '/top-sources',
290
- defineEventHandler(async (event) => {
291
- const { accessToken, propertyId } = await getGAContext(event)
292
- const query = getQuery(event)
293
- const startDate = (query.startDate as string) || '30daysAgo'
294
- const endDate = (query.endDate as string) || 'today'
295
-
296
- const raw = await runReport(accessToken, propertyId, {
297
- dateRanges: [{ startDate, endDate }],
298
- dimensions: [{ name: 'sessionSource' }],
299
- metrics: [
300
- { name: 'sessions' },
301
- { name: 'totalUsers' },
302
- ],
303
- orderBys: [{ metric: { metricName: 'sessions' }, desc: true }],
304
- limit: 10,
305
- })
306
-
307
- return transformDimensionReport(raw, 'sessionSource')
308
- }),
309
- )
310
-
311
- // ── GET /devices ─────────────────────────────────────
312
- router.get(
313
- '/devices',
314
- defineEventHandler(async (event) => {
315
- const { accessToken, propertyId } = await getGAContext(event)
316
- const query = getQuery(event)
317
- const startDate = (query.startDate as string) || '30daysAgo'
318
- const endDate = (query.endDate as string) || 'today'
319
-
320
- const raw = await runReport(accessToken, propertyId, {
321
- dateRanges: [{ startDate, endDate }],
322
- dimensions: [{ name: 'deviceCategory' }],
323
- metrics: [
324
- { name: 'sessions' },
325
- { name: 'totalUsers' },
326
- ],
327
- orderBys: [{ metric: { metricName: 'sessions' }, desc: true }],
328
- })
329
-
330
- return transformDimensionReport(raw, 'deviceCategory')
331
- }),
332
- )
333
-
334
- // ── GET /countries ───────────────────────────────────
335
- router.get(
336
- '/countries',
337
- defineEventHandler(async (event) => {
338
- const { accessToken, propertyId } = await getGAContext(event)
339
- const query = getQuery(event)
340
- const startDate = (query.startDate as string) || '30daysAgo'
341
- const endDate = (query.endDate as string) || 'today'
342
-
343
- const raw = await runReport(accessToken, propertyId, {
344
- dateRanges: [{ startDate, endDate }],
345
- dimensions: [{ name: 'country' }],
346
- metrics: [
347
- { name: 'sessions' },
348
- { name: 'totalUsers' },
349
- ],
350
- orderBys: [{ metric: { metricName: 'sessions' }, desc: true }],
351
- limit: 10,
352
- })
353
-
354
- return transformDimensionReport(raw, 'country')
355
- }),
356
- )
357
-
358
- // ═══════════════════════════════════════════════════════
359
- // ACQUISITION ROUTES
360
- // ═══════════════════════════════════════════════════════
361
-
362
- // ── GET /acquisition/channels ────────────────────────
363
- router.get(
364
- '/acquisition/channels',
365
- defineEventHandler(async (event) => {
366
- const { accessToken, propertyId } = await getGAContext(event)
367
- const query = getQuery(event)
368
- const startDate = (query.startDate as string) || '30daysAgo'
369
- const endDate = (query.endDate as string) || 'today'
370
-
371
- const raw = await runReport(accessToken, propertyId, {
372
- dateRanges: [{ startDate, endDate }],
373
- dimensions: [{ name: 'sessionDefaultChannelGroup' }],
374
- metrics: [
375
- { name: 'sessions' },
376
- { name: 'totalUsers' },
377
- { name: 'newUsers' },
378
- { name: 'engagementRate' },
379
- { name: 'averageSessionDuration' },
380
- { name: 'screenPageViewsPerSession' },
381
- { name: 'conversions' },
382
- ],
383
- orderBys: [{ metric: { metricName: 'sessions' }, desc: true }],
384
- limit: 15,
385
- })
386
-
387
- return transformDimensionReport(raw, 'sessionDefaultChannelGroup')
388
- }),
389
- )
390
-
391
- // ── GET /acquisition/source-medium ───────────────────
392
- router.get(
393
- '/acquisition/source-medium',
394
- defineEventHandler(async (event) => {
395
- const { accessToken, propertyId } = await getGAContext(event)
396
- const query = getQuery(event)
397
- const startDate = (query.startDate as string) || '30daysAgo'
398
- const endDate = (query.endDate as string) || 'today'
399
-
400
- const raw = await runReport(accessToken, propertyId, {
401
- dateRanges: [{ startDate, endDate }],
402
- dimensions: [{ name: 'sessionSourceMedium' }],
403
- metrics: [
404
- { name: 'sessions' },
405
- { name: 'totalUsers' },
406
- { name: 'newUsers' },
407
- { name: 'bounceRate' },
408
- { name: 'averageSessionDuration' },
409
- { name: 'conversions' },
410
- ],
411
- orderBys: [{ metric: { metricName: 'sessions' }, desc: true }],
412
- limit: 20,
413
- })
414
-
415
- return transformDimensionReport(raw, 'sessionSourceMedium')
416
- }),
417
- )
418
-
419
- // ── GET /acquisition/referrals ───────────────────────
420
- router.get(
421
- '/acquisition/referrals',
422
- defineEventHandler(async (event) => {
423
- const { accessToken, propertyId } = await getGAContext(event)
424
- const query = getQuery(event)
425
- const startDate = (query.startDate as string) || '30daysAgo'
426
- const endDate = (query.endDate as string) || 'today'
427
-
428
- const raw = await runReport(accessToken, propertyId, {
429
- dateRanges: [{ startDate, endDate }],
430
- dimensions: [{ name: 'sessionSource' }],
431
- dimensionFilter: {
432
- filter: {
433
- fieldName: 'sessionMedium',
434
- stringFilter: { value: 'referral' },
435
- },
436
- },
437
- metrics: [
438
- { name: 'sessions' },
439
- { name: 'totalUsers' },
440
- { name: 'engagementRate' },
441
- { name: 'averageSessionDuration' },
442
- ],
443
- orderBys: [{ metric: { metricName: 'sessions' }, desc: true }],
444
- limit: 20,
445
- })
446
-
447
- return transformDimensionReport(raw, 'sessionSource')
448
- }),
449
- )
450
-
451
- // ── GET /acquisition/campaigns ───────────────────────
452
- router.get(
453
- '/acquisition/campaigns',
454
- defineEventHandler(async (event) => {
455
- const { accessToken, propertyId } = await getGAContext(event)
456
- const query = getQuery(event)
457
- const startDate = (query.startDate as string) || '30daysAgo'
458
- const endDate = (query.endDate as string) || 'today'
459
-
460
- const raw = await runReport(accessToken, propertyId, {
461
- dateRanges: [{ startDate, endDate }],
462
- dimensions: [{ name: 'sessionCampaignName' }],
463
- dimensionFilter: {
464
- notExpression: {
465
- filter: {
466
- fieldName: 'sessionCampaignName',
467
- stringFilter: { value: '(not set)' },
468
- },
469
- },
470
- },
471
- metrics: [
472
- { name: 'sessions' },
473
- { name: 'totalUsers' },
474
- { name: 'conversions' },
475
- { name: 'engagementRate' },
476
- ],
477
- orderBys: [{ metric: { metricName: 'sessions' }, desc: true }],
478
- limit: 20,
479
- })
480
-
481
- return transformDimensionReport(raw, 'sessionCampaignName')
482
- }),
483
- )
484
-
485
- // ═══════════════════════════════════════════════════════
486
- // CONTENT / SEO ROUTES
487
- // ═══════════════════════════════════════════════════════
488
-
489
- // ── GET /content/all-pages ───────────────────────────
490
- router.get(
491
- '/content/all-pages',
492
- defineEventHandler(async (event) => {
493
- const { accessToken, propertyId } = await getGAContext(event)
494
- const query = getQuery(event)
495
- const startDate = (query.startDate as string) || '30daysAgo'
496
- const endDate = (query.endDate as string) || 'today'
497
- const limit = parseInt((query.limit as string) || '50', 10)
498
-
499
- const raw = await runReport(accessToken, propertyId, {
500
- dateRanges: [{ startDate, endDate }],
501
- dimensions: [{ name: 'pagePath' }],
502
- metrics: [
503
- { name: 'screenPageViews' },
504
- { name: 'totalUsers' },
505
- { name: 'averageSessionDuration' },
506
- { name: 'bounceRate' },
507
- { name: 'engagementRate' },
508
- { name: 'sessions' },
509
- { name: 'userEngagementDuration' },
510
- ],
511
- orderBys: [{ metric: { metricName: 'screenPageViews' }, desc: true }],
512
- limit,
513
- })
514
-
515
- return transformDimensionReport(raw, 'pagePath')
516
- }),
517
- )
518
-
519
- // ── GET /content/landing-pages ───────────────────────
520
- router.get(
521
- '/content/landing-pages',
522
- defineEventHandler(async (event) => {
523
- const { accessToken, propertyId } = await getGAContext(event)
524
- const query = getQuery(event)
525
- const startDate = (query.startDate as string) || '30daysAgo'
526
- const endDate = (query.endDate as string) || 'today'
527
-
528
- const raw = await runReport(accessToken, propertyId, {
529
- dateRanges: [{ startDate, endDate }],
530
- dimensions: [{ name: 'landingPagePlusQueryString' }],
531
- metrics: [
532
- { name: 'sessions' },
533
- { name: 'totalUsers' },
534
- { name: 'bounceRate' },
535
- { name: 'averageSessionDuration' },
536
- { name: 'screenPageViews' },
537
- { name: 'engagementRate' },
538
- ],
539
- orderBys: [{ metric: { metricName: 'sessions' }, desc: true }],
540
- limit: 30,
541
- })
542
-
543
- return transformDimensionReport(raw, 'landingPagePlusQueryString')
544
- }),
545
- )
546
-
547
- // ── GET /content/exit-pages ──────────────────────────
548
- router.get(
549
- '/content/exit-pages',
550
- defineEventHandler(async (event) => {
551
- const { accessToken, propertyId } = await getGAContext(event)
552
- const query = getQuery(event)
553
- const startDate = (query.startDate as string) || '30daysAgo'
554
- const endDate = (query.endDate as string) || 'today'
555
-
556
- const raw = await runReport(accessToken, propertyId, {
557
- dateRanges: [{ startDate, endDate }],
558
- dimensions: [{ name: 'pagePath' }],
559
- metrics: [
560
- { name: 'sessions' },
561
- { name: 'screenPageViews' },
562
- { name: 'totalUsers' },
563
- { name: 'bounceRate' },
564
- ],
565
- orderBys: [{ metric: { metricName: 'screenPageViews' }, desc: true }],
566
- limit: 20,
567
- })
568
-
569
- // Compute approximate exit rate from bounce rate
570
- const result = transformDimensionReport(raw, 'pagePath')
571
- result.rows = result.rows.map((row: any) => ({
572
- ...row,
573
- exitRate: row.bounceRate || 0,
574
- }))
575
- return result
576
- }),
577
- )
578
-
579
- // ── GET /content/search-terms ──────────────────────────
580
- // Step 1: Try Google Search Console (real organic keywords).
581
- // Step 2: Try Google Ads search queries from GA4.
582
- // Step 3: Fall back to top organic-search landing pages.
583
- router.get(
584
- '/content/search-terms',
585
- defineEventHandler(async (event) => {
586
- const { accessToken, propertyId, siteUrl } = await getGAContext(event)
587
- const query = getQuery(event)
588
- const startDate = (query.startDate as string) || '30daysAgo'
589
- const endDate = (query.endDate as string) || 'today'
590
-
591
- // 1) Try Google Search Console if siteUrl is configured
592
- if (siteUrl) {
593
- try {
594
- const gscUrl = `${SEARCH_CONSOLE_API}/sites/${encodeURIComponent(siteUrl)}/searchAnalytics/query`
595
- console.log('[GA Module] Attempting Search Console query for site:', siteUrl)
596
- const gscRes = await fetch(
597
- gscUrl,
598
- {
599
- method: 'POST',
600
- headers: {
601
- Authorization: `Bearer ${accessToken}`,
602
- 'Content-Type': 'application/json',
603
- },
604
- body: JSON.stringify({
605
- startDate: resolveDate(startDate),
606
- endDate: resolveDate(endDate),
607
- dimensions: ['query'],
608
- rowLimit: 30,
609
- }),
610
- },
611
- )
612
-
613
- if (gscRes.ok) {
614
- const gscData = await gscRes.json()
615
- const rows = (gscData.rows || []).map((row: any) => ({
616
- query: row.keys[0],
617
- clicks: row.clicks,
618
- impressions: row.impressions,
619
- ctr: row.ctr,
620
- position: row.position,
621
- }))
622
-
623
- if (rows.length > 0) {
624
- console.log(`[GA Module] Search Console returned ${rows.length} keyword rows`)
625
- return {
626
- rows,
627
- rowCount: rows.length,
628
- source: 'search_console',
629
- }
630
- }
631
- console.log('[GA Module] Search Console returned 0 rows, falling through')
632
- } else {
633
- const errBody = await gscRes.text().catch(() => '')
634
- console.error(`[GA Module] Search Console API error (${gscRes.status}):`, errBody)
635
- }
636
- } catch (gscErr: any) {
637
- console.error('[GA Module] Search Console request failed:', gscErr?.message || gscErr)
638
- }
639
- } else {
640
- console.log('[GA Module] No siteUrl configured, skipping Search Console')
641
- }
642
-
643
- // 2) Try sessionGoogleAdsQuery (real search query text from Google Ads)
644
- try {
645
- const adsRaw = await runReport(accessToken, propertyId, {
646
- dateRanges: [{ startDate, endDate }],
647
- dimensions: [{ name: 'sessionGoogleAdsQuery' }],
648
- metrics: [
649
- { name: 'sessions' },
650
- { name: 'totalUsers' },
651
- { name: 'engagementRate' },
652
- ],
653
- dimensionFilter: {
654
- andGroup: {
655
- expressions: [
656
- {
657
- notExpression: {
658
- filter: {
659
- fieldName: 'sessionGoogleAdsQuery',
660
- stringFilter: { value: '(not set)' },
661
- },
662
- },
663
- },
664
- {
665
- notExpression: {
666
- filter: {
667
- fieldName: 'sessionGoogleAdsQuery',
668
- stringFilter: { value: '(not provided)' },
669
- },
670
- },
671
- },
672
- ],
673
- },
674
- },
675
- orderBys: [{ metric: { metricName: 'sessions' }, desc: true }],
676
- limit: 30,
677
- })
678
-
679
- const adsResult = transformDimensionReport(adsRaw, 'sessionGoogleAdsQuery')
680
- if (adsResult.rows.length > 0) {
681
- return { ...adsResult, source: 'google_ads' }
682
- }
683
- } catch {
684
- // Dimension may not be available — fall through to organic landing pages
685
- }
686
-
687
- // 3) Fallback: top landing pages from organic search traffic
688
- const organicRaw = await runReport(accessToken, propertyId, {
689
- dateRanges: [{ startDate, endDate }],
690
- dimensions: [{ name: 'landingPagePlusQueryString' }],
691
- metrics: [
692
- { name: 'sessions' },
693
- { name: 'totalUsers' },
694
- { name: 'engagementRate' },
695
- ],
696
- dimensionFilter: {
697
- andGroup: {
698
- expressions: [
699
- {
700
- filter: {
701
- fieldName: 'sessionMedium',
702
- stringFilter: {
703
- matchType: 'EXACT',
704
- value: 'organic',
705
- },
706
- },
707
- },
708
- {
709
- notExpression: {
710
- filter: {
711
- fieldName: 'landingPagePlusQueryString',
712
- stringFilter: { value: '(not set)' },
713
- },
714
- },
715
- },
716
- ],
717
- },
718
- },
719
- orderBys: [{ metric: { metricName: 'sessions' }, desc: true }],
720
- limit: 30,
721
- })
722
-
723
- const organicResult = transformDimensionReport(organicRaw, 'landingPagePlusQueryString')
724
- return { ...organicResult, source: 'organic_landing_pages' }
725
- }),
726
- )
727
-
728
- // ═══════════════════════════════════════════════════════
729
- // AUDIENCE ROUTES
730
- // ═══════════════════════════════════════════════════════
731
-
732
- // ── GET /audience/overview ───────────────────────────
733
- router.get(
734
- '/audience/overview',
735
- defineEventHandler(async (event) => {
736
- const { accessToken, propertyId } = await getGAContext(event)
737
- const query = getQuery(event)
738
- const startDate = (query.startDate as string) || '30daysAgo'
739
- const endDate = (query.endDate as string) || 'today'
740
-
741
- const raw = await runReport(accessToken, propertyId, {
742
- dateRanges: [{ startDate, endDate }],
743
- dimensions: [{ name: 'newVsReturning' }],
744
- metrics: [
745
- { name: 'totalUsers' },
746
- { name: 'sessions' },
747
- { name: 'engagementRate' },
748
- { name: 'averageSessionDuration' },
749
- { name: 'screenPageViewsPerSession' },
750
- ],
751
- metricAggregations: ['TOTAL'],
752
- })
753
-
754
- return transformDimensionReport(raw, 'newVsReturning')
755
- }),
756
- )
757
-
758
- // ── GET /audience/technology ─────────────────────────
759
- router.get(
760
- '/audience/technology',
761
- defineEventHandler(async (event) => {
762
- const { accessToken, propertyId } = await getGAContext(event)
763
- const query = getQuery(event)
764
- const startDate = (query.startDate as string) || '30daysAgo'
765
- const endDate = (query.endDate as string) || 'today'
766
- const dimension = (query.dimension as string) || 'browser'
767
-
768
- // Allow switching between browser, operatingSystem, screenResolution
769
- const allowedDimensions = ['browser', 'operatingSystem', 'screenResolution']
770
- const dim = allowedDimensions.includes(dimension) ? dimension : 'browser'
771
-
772
- const raw = await runReport(accessToken, propertyId, {
773
- dateRanges: [{ startDate, endDate }],
774
- dimensions: [{ name: dim }],
775
- metrics: [
776
- { name: 'totalUsers' },
777
- { name: 'sessions' },
778
- { name: 'engagementRate' },
779
- ],
780
- orderBys: [{ metric: { metricName: 'totalUsers' }, desc: true }],
781
- limit: 10,
782
- })
783
-
784
- return transformDimensionReport(raw, dim)
785
- }),
786
- )
787
-
788
- // ── GET /audience/languages ──────────────────────────
789
- router.get(
790
- '/audience/languages',
791
- defineEventHandler(async (event) => {
792
- const { accessToken, propertyId } = await getGAContext(event)
793
- const query = getQuery(event)
794
- const startDate = (query.startDate as string) || '30daysAgo'
795
- const endDate = (query.endDate as string) || 'today'
796
-
797
- const raw = await runReport(accessToken, propertyId, {
798
- dateRanges: [{ startDate, endDate }],
799
- dimensions: [{ name: 'language' }],
800
- metrics: [
801
- { name: 'totalUsers' },
802
- { name: 'sessions' },
803
- ],
804
- orderBys: [{ metric: { metricName: 'totalUsers' }, desc: true }],
805
- limit: 15,
806
- })
807
-
808
- return transformDimensionReport(raw, 'language')
809
- }),
810
- )
811
-
812
- // ── GET /audience/hours ──────────────────────────────
813
- // Peak traffic hours heatmap data
814
- router.get(
815
- '/audience/hours',
816
- defineEventHandler(async (event) => {
817
- const { accessToken, propertyId } = await getGAContext(event)
818
- const query = getQuery(event)
819
- const startDate = (query.startDate as string) || '30daysAgo'
820
- const endDate = (query.endDate as string) || 'today'
821
-
822
- const raw = await runReport(accessToken, propertyId, {
823
- dateRanges: [{ startDate, endDate }],
824
- dimensions: [{ name: 'dayOfWeekName' }, { name: 'hour' }],
825
- metrics: [{ name: 'sessions' }],
826
- orderBys: [
827
- { dimension: { dimensionName: 'dayOfWeekName' } },
828
- { dimension: { dimensionName: 'hour' } },
829
- ],
830
- limit: 168, // 7 days × 24 hours
831
- })
832
-
833
- return transformMultiDimensionReport(raw, ['dayOfWeekName', 'hour'])
834
- }),
835
- )
836
-
837
- // ── GET /audience/cities ─────────────────────────────
838
- router.get(
839
- '/audience/cities',
840
- defineEventHandler(async (event) => {
841
- const { accessToken, propertyId } = await getGAContext(event)
842
- const query = getQuery(event)
843
- const startDate = (query.startDate as string) || '30daysAgo'
844
- const endDate = (query.endDate as string) || 'today'
845
-
846
- const raw = await runReport(accessToken, propertyId, {
847
- dateRanges: [{ startDate, endDate }],
848
- dimensions: [{ name: 'city' }, { name: 'country' }],
849
- metrics: [
850
- { name: 'totalUsers' },
851
- { name: 'sessions' },
852
- ],
853
- dimensionFilter: {
854
- notExpression: {
855
- filter: {
856
- fieldName: 'city',
857
- stringFilter: { value: '(not set)' },
858
- },
859
- },
860
- },
861
- orderBys: [{ metric: { metricName: 'totalUsers' }, desc: true }],
862
- limit: 20,
863
- })
864
-
865
- return transformMultiDimensionReport(raw, ['city', 'country'])
866
- }),
867
- )
868
-
869
- // ═══════════════════════════════════════════════════════
870
- // SEO / SEARCH CONSOLE ROUTES
871
- // ═══════════════════════════════════════════════════════
872
-
873
- /**
874
- * Helper: resolve relative GA dates like "30daysAgo" to YYYY-MM-DD for Search Console.
875
- */
876
- function resolveDate(d: string): string {
877
- const relative = d.match(/^(\d+)daysAgo$/)
878
- if (relative) {
879
- const dt = new Date()
880
- dt.setDate(dt.getDate() - parseInt(relative[1], 10))
881
- return dt.toISOString().slice(0, 10)
882
- }
883
- if (d === 'today') return new Date().toISOString().slice(0, 10)
884
- if (d === 'yesterday') {
885
- const dt = new Date()
886
- dt.setDate(dt.getDate() - 1)
887
- return dt.toISOString().slice(0, 10)
888
- }
889
- return d
890
- }
891
-
892
- /**
893
- * Call the Search Console searchAnalytics/query endpoint.
894
- */
895
- async function runSearchConsoleQuery(
896
- accessToken: string,
897
- siteUrl: string,
898
- body: Record<string, any>,
899
- ) {
900
- const url = `${SEARCH_CONSOLE_API}/sites/${encodeURIComponent(siteUrl)}/searchAnalytics/query`
901
- const res = await fetch(url, {
902
- method: 'POST',
903
- headers: {
904
- Authorization: `Bearer ${accessToken}`,
905
- 'Content-Type': 'application/json',
906
- },
907
- body: JSON.stringify(body),
908
- })
909
- if (!res.ok) {
910
- const err = await res.json().catch(() => ({}))
911
- throw createError({
912
- statusCode: res.status,
913
- statusMessage: err?.error?.message || 'Search Console API request failed',
914
- })
915
- }
916
- return res.json()
917
- }
918
-
919
- // ── GET /seo/keywords ────────────────────────────────
920
- // Top search queries with clicks, impressions, CTR, position
921
- router.get(
922
- '/seo/keywords',
923
- defineEventHandler(async (event) => {
924
- const { accessToken, siteUrl } = await getGAContext(event)
925
- if (!siteUrl) {
926
- throw createError({ statusCode: 400, statusMessage: 'Search Console Site URL is not configured. Add it in module settings.' })
927
- }
928
-
929
- const query = getQuery(event)
930
- const startDate = resolveDate((query.startDate as string) || '30daysAgo')
931
- const endDate = resolveDate((query.endDate as string) || 'today')
932
- const limit = Math.min(parseInt(query.limit as string) || 50, 100)
933
-
934
- const data = await runSearchConsoleQuery(accessToken, siteUrl, {
935
- startDate,
936
- endDate,
937
- dimensions: ['query'],
938
- rowLimit: limit,
939
- })
940
-
941
- return {
942
- rows: (data.rows || []).map((r: any) => ({
943
- query: r.keys[0],
944
- clicks: r.clicks,
945
- impressions: r.impressions,
946
- ctr: r.ctr,
947
- position: r.position,
948
- })),
949
- rowCount: data.rows?.length || 0,
950
- }
951
- }),
952
- )
953
-
954
- // ── GET /seo/pages ───────────────────────────────────
955
- // Top pages by Search Console performance
956
- router.get(
957
- '/seo/pages',
958
- defineEventHandler(async (event) => {
959
- const { accessToken, siteUrl } = await getGAContext(event)
960
- if (!siteUrl) {
961
- throw createError({ statusCode: 400, statusMessage: 'Search Console Site URL is not configured.' })
962
- }
963
-
964
- const query = getQuery(event)
965
- const startDate = resolveDate((query.startDate as string) || '30daysAgo')
966
- const endDate = resolveDate((query.endDate as string) || 'today')
967
- const limit = Math.min(parseInt(query.limit as string) || 50, 100)
968
-
969
- const data = await runSearchConsoleQuery(accessToken, siteUrl, {
970
- startDate,
971
- endDate,
972
- dimensions: ['page'],
973
- rowLimit: limit,
974
- })
975
-
976
- return {
977
- rows: (data.rows || []).map((r: any) => ({
978
- page: r.keys[0],
979
- clicks: r.clicks,
980
- impressions: r.impressions,
981
- ctr: r.ctr,
982
- position: r.position,
983
- })),
984
- rowCount: data.rows?.length || 0,
985
- }
986
- }),
987
- )
988
-
989
- // ── GET /seo/trends ──────────────────────────────────
990
- // Daily clicks/impressions/CTR/position trend data
991
- router.get(
992
- '/seo/trends',
993
- defineEventHandler(async (event) => {
994
- const { accessToken, siteUrl } = await getGAContext(event)
995
- if (!siteUrl) {
996
- throw createError({ statusCode: 400, statusMessage: 'Search Console Site URL is not configured.' })
997
- }
998
-
999
- const query = getQuery(event)
1000
- const startDate = resolveDate((query.startDate as string) || '30daysAgo')
1001
- const endDate = resolveDate((query.endDate as string) || 'today')
1002
-
1003
- const data = await runSearchConsoleQuery(accessToken, siteUrl, {
1004
- startDate,
1005
- endDate,
1006
- dimensions: ['date'],
1007
- rowLimit: 500,
1008
- })
1009
-
1010
- const rows = (data.rows || [])
1011
- .map((r: any) => ({
1012
- date: r.keys[0],
1013
- clicks: r.clicks,
1014
- impressions: r.impressions,
1015
- ctr: r.ctr,
1016
- position: r.position,
1017
- }))
1018
- .sort((a: any, b: any) => a.date.localeCompare(b.date))
1019
-
1020
- // Compute totals
1021
- const totals = rows.reduce(
1022
- (acc: any, r: any) => ({
1023
- clicks: acc.clicks + r.clicks,
1024
- impressions: acc.impressions + r.impressions,
1025
- }),
1026
- { clicks: 0, impressions: 0 },
1027
- )
1028
- totals.ctr = totals.impressions > 0 ? totals.clicks / totals.impressions : 0
1029
- totals.avgPosition =
1030
- rows.length > 0 ? rows.reduce((s: number, r: any) => s + r.position, 0) / rows.length : 0
1031
-
1032
- return { rows, totals, rowCount: rows.length }
1033
- }),
1034
- )
1035
-
1036
- // ── GET /seo/query-pages ─────────────────────────────
1037
- // Top queries grouped by page (for keyword-to-page mapping)
1038
- router.get(
1039
- '/seo/query-pages',
1040
- defineEventHandler(async (event) => {
1041
- const { accessToken, siteUrl } = await getGAContext(event)
1042
- if (!siteUrl) {
1043
- throw createError({ statusCode: 400, statusMessage: 'Search Console Site URL is not configured.' })
1044
- }
1045
-
1046
- const query = getQuery(event)
1047
- const startDate = resolveDate((query.startDate as string) || '30daysAgo')
1048
- const endDate = resolveDate((query.endDate as string) || 'today')
1049
-
1050
- const data = await runSearchConsoleQuery(accessToken, siteUrl, {
1051
- startDate,
1052
- endDate,
1053
- dimensions: ['query', 'page'],
1054
- rowLimit: 100,
1055
- })
1056
-
1057
- return {
1058
- rows: (data.rows || []).map((r: any) => ({
1059
- query: r.keys[0],
1060
- page: r.keys[1],
1061
- clicks: r.clicks,
1062
- impressions: r.impressions,
1063
- ctr: r.ctr,
1064
- position: r.position,
1065
- })),
1066
- rowCount: data.rows?.length || 0,
1067
- }
1068
- }),
1069
- )
1070
-
1071
- return router.handler
1072
- }
1073
-
1074
- // ═══════════════════════════════════════════════════════
1075
- // RESPONSE TRANSFORMERS
1076
- // ═══════════════════════════════════════════════════════
1077
-
1078
- /**
1079
- * Transform a GA4 date-dimensioned report with two date ranges into a comparison report.
1080
- * Returns current period rows, current totals, and previous totals for % change.
1081
- */
1082
- function transformComparisonReport(raw: any, hasComparison = true) {
1083
- const metricHeaders = (raw.metricHeaders || []).map((h: any) => h.name)
1084
- const dimHeaders = (raw.dimensionHeaders || []).map((h: any) => h.name)
1085
- const dateIdx = dimHeaders.indexOf('date')
1086
-
1087
- const allRows: any[] = []
1088
-
1089
- ;(raw.rows || []).forEach((row: any) => {
1090
- const dateVal = dateIdx >= 0 ? row.dimensionValues[dateIdx]?.value : row.dimensionValues[0]?.value
1091
- if (!dateVal || dateVal.length < 8) return
1092
-
1093
- const date = `${dateVal.slice(0, 4)}-${dateVal.slice(4, 6)}-${dateVal.slice(6, 8)}`
1094
- const entry: Record<string, any> = { date }
1095
- metricHeaders.forEach((metric: string, i: number) => {
1096
- entry[metric] = parseFloat(row.metricValues[i]?.value || '0')
1097
- })
1098
- allRows.push(entry)
1099
- })
1100
-
1101
- // Extract totals
1102
- const currentTotals: Record<string, number> = {}
1103
- const previousTotals: Record<string, number> = {}
1104
-
1105
- if (raw.totals && raw.totals.length >= 2) {
1106
- metricHeaders.forEach((metric: string, i: number) => {
1107
- currentTotals[metric] = parseFloat(raw.totals[0]?.metricValues?.[i]?.value || '0')
1108
- previousTotals[metric] = parseFloat(raw.totals[1]?.metricValues?.[i]?.value || '0')
1109
- })
1110
- } else if (raw.totals && raw.totals.length === 1) {
1111
- metricHeaders.forEach((metric: string, i: number) => {
1112
- currentTotals[metric] = parseFloat(raw.totals[0]?.metricValues?.[i]?.value || '0')
1113
- })
1114
- }
1115
-
1116
- // Compute % change
1117
- const changes: Record<string, number | null> = {}
1118
- metricHeaders.forEach((metric: string) => {
1119
- const curr = currentTotals[metric] || 0
1120
- const prev = previousTotals[metric]
1121
- if (prev !== undefined && prev !== 0) {
1122
- changes[metric] = ((curr - prev) / prev) * 100
1123
- } else {
1124
- changes[metric] = null
1125
- }
1126
- })
1127
-
1128
- // Deduplicate by date, sort chronologically
1129
- const byDate = new Map<string, any>()
1130
- for (const row of allRows) {
1131
- if (!byDate.has(row.date)) {
1132
- byDate.set(row.date, row)
1133
- }
1134
- }
1135
- const rows = Array.from(byDate.values()).sort((a, b) => a.date.localeCompare(b.date))
1136
-
1137
- // If we had a comparison (dual date ranges), only keep the more recent half (current period)
1138
- const chartRows = hasComparison && raw.totals && raw.totals.length >= 2
1139
- ? rows.slice(-Math.ceil(rows.length / 2))
1140
- : rows
1141
-
1142
- return {
1143
- rows: chartRows,
1144
- totals: currentTotals,
1145
- previousTotals,
1146
- changes,
1147
- rowCount: chartRows.length,
1148
- }
1149
- }
1150
-
1151
- /**
1152
- * Transform a GA4 single-dimension report into a ranked list.
1153
- */
1154
- function transformDimensionReport(raw: any, dimensionKey: string) {
1155
- const metricHeaders = (raw.metricHeaders || []).map((h: any) => h.name)
1156
-
1157
- const rows = (raw.rows || []).map((row: any) => {
1158
- const entry: Record<string, any> = {
1159
- [dimensionKey]: row.dimensionValues[0].value,
1160
- }
1161
- metricHeaders.forEach((metric: string, i: number) => {
1162
- entry[metric] = parseFloat(row.metricValues[i].value)
1163
- })
1164
- return entry
1165
- })
1166
-
1167
- return { rows, rowCount: raw.rowCount || rows.length }
1168
- }
1169
-
1170
- /**
1171
- * Transform a GA4 multi-dimension report (e.g. pagePath + pageTitle, or dayOfWeek + hour).
1172
- */
1173
- function transformMultiDimensionReport(raw: any, dimensionKeys: string[]) {
1174
- const metricHeaders = (raw.metricHeaders || []).map((h: any) => h.name)
1175
-
1176
- const rows = (raw.rows || []).map((row: any) => {
1177
- const entry: Record<string, any> = {}
1178
- dimensionKeys.forEach((key, di) => {
1179
- entry[key] = row.dimensionValues[di]?.value || ''
1180
- })
1181
- metricHeaders.forEach((metric: string, i: number) => {
1182
- entry[metric] = parseFloat(row.metricValues[i].value)
1183
- })
1184
- return entry
1185
- })
1186
-
1187
- return { rows, rowCount: raw.rowCount || rows.length }
1188
- }