@coursebuilder/analytics 1.1.0

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.
Files changed (56) hide show
  1. package/LICENSE +21 -0
  2. package/dist/api/index.d.ts +158 -0
  3. package/dist/api/index.js +317 -0
  4. package/dist/api/index.js.map +1 -0
  5. package/dist/catalog.d.ts +14 -0
  6. package/dist/catalog.js +209 -0
  7. package/dist/catalog.js.map +1 -0
  8. package/dist/components/index.d.ts +172 -0
  9. package/dist/components/index.js +1258 -0
  10. package/dist/components/index.js.map +1 -0
  11. package/dist/engine.d.ts +20 -0
  12. package/dist/engine.js +350 -0
  13. package/dist/engine.js.map +1 -0
  14. package/dist/index.d.ts +3 -0
  15. package/dist/index.js +353 -0
  16. package/dist/index.js.map +1 -0
  17. package/dist/providers/database.d.ts +79 -0
  18. package/dist/providers/database.js +533 -0
  19. package/dist/providers/database.js.map +1 -0
  20. package/dist/providers/derived.d.ts +45 -0
  21. package/dist/providers/derived.js +32 -0
  22. package/dist/providers/derived.js.map +1 -0
  23. package/dist/providers/ga4.d.ts +43 -0
  24. package/dist/providers/ga4.js +220 -0
  25. package/dist/providers/ga4.js.map +1 -0
  26. package/dist/providers/index.d.ts +8 -0
  27. package/dist/providers/index.js +1239 -0
  28. package/dist/providers/index.js.map +1 -0
  29. package/dist/providers/mux.d.ts +103 -0
  30. package/dist/providers/mux.js +241 -0
  31. package/dist/providers/mux.js.map +1 -0
  32. package/dist/providers/survey.d.ts +102 -0
  33. package/dist/providers/survey.js +233 -0
  34. package/dist/providers/survey.js.map +1 -0
  35. package/dist/types.d.ts +303 -0
  36. package/dist/types.js +1 -0
  37. package/dist/types.js.map +1 -0
  38. package/package.json +101 -0
  39. package/src/api/catalog-handler.ts +321 -0
  40. package/src/api/index.ts +4 -0
  41. package/src/api/token-handler.ts +71 -0
  42. package/src/catalog.ts +223 -0
  43. package/src/components/country-chart.tsx +114 -0
  44. package/src/components/index.ts +5 -0
  45. package/src/components/omnibus-dashboard.tsx +1460 -0
  46. package/src/components/revenue-chart.tsx +251 -0
  47. package/src/components/use-chart-colors.ts +75 -0
  48. package/src/engine.ts +201 -0
  49. package/src/index.ts +7 -0
  50. package/src/providers/database.ts +795 -0
  51. package/src/providers/derived.ts +79 -0
  52. package/src/providers/ga4.ts +173 -0
  53. package/src/providers/index.ts +44 -0
  54. package/src/providers/mux.ts +438 -0
  55. package/src/providers/survey.ts +487 -0
  56. package/src/types.ts +333 -0
@@ -0,0 +1,1239 @@
1
+ var __defProp = Object.defineProperty;
2
+ var __name = (target, value) => __defProp(target, "name", { value, configurable: true });
3
+
4
+ // src/providers/database.ts
5
+ import { and, count, desc, eq, gt, gte, inArray, lte, sql, sum } from "drizzle-orm";
6
+ function createDatabaseProvider(db, schema) {
7
+ const { purchases, products, users, coupon, resourceProgress, shortlink, shortlinkAttribution, shortlinkClick } = schema;
8
+ const PAID_STATUSES = [
9
+ "Valid",
10
+ "Restricted"
11
+ ];
12
+ function paidPurchase() {
13
+ return inArray(purchases.status, [
14
+ ...PAID_STATUSES
15
+ ]);
16
+ }
17
+ __name(paidPurchase, "paidPurchase");
18
+ function rangeToDate(range) {
19
+ if (range === "all")
20
+ return null;
21
+ const now = /* @__PURE__ */ new Date();
22
+ const hours = {
23
+ "24h": 24,
24
+ "7d": 7 * 24,
25
+ "30d": 30 * 24,
26
+ "90d": 90 * 24
27
+ };
28
+ return new Date(now.getTime() - (hours[range] ?? 30 * 24) * 60 * 60 * 1e3);
29
+ }
30
+ __name(rangeToDate, "rangeToDate");
31
+ async function getRevenueSummary(range = "30d") {
32
+ const since = rangeToDate(range);
33
+ const conditions = [
34
+ paidPurchase()
35
+ ];
36
+ if (since)
37
+ conditions.push(gte(purchases.createdAt, since));
38
+ const [totals] = await db.select({
39
+ totalRevenue: sum(purchases.totalAmount),
40
+ purchaseCount: count()
41
+ }).from(purchases).where(and(...conditions));
42
+ return {
43
+ totalRevenue: Number(totals?.totalRevenue ?? 0),
44
+ purchaseCount: totals?.purchaseCount ?? 0,
45
+ avgOrderValue: totals?.purchaseCount && totals.purchaseCount > 0 ? Number(totals.totalRevenue ?? 0) / totals.purchaseCount : 0
46
+ };
47
+ }
48
+ __name(getRevenueSummary, "getRevenueSummary");
49
+ async function getRevenueByDay(range = "30d") {
50
+ const since = rangeToDate(range);
51
+ const conditions = [
52
+ paidPurchase()
53
+ ];
54
+ if (since)
55
+ conditions.push(gte(purchases.createdAt, since));
56
+ const rows = await db.select({
57
+ date: sql`DATE(${purchases.createdAt})`.as("date"),
58
+ revenue: sum(purchases.totalAmount),
59
+ count: count()
60
+ }).from(purchases).where(and(...conditions)).groupBy(sql`DATE(${purchases.createdAt})`).orderBy(sql`DATE(${purchases.createdAt})`);
61
+ return rows.map((r) => ({
62
+ date: r.date,
63
+ revenue: Number(r.revenue ?? 0),
64
+ count: r.count
65
+ }));
66
+ }
67
+ __name(getRevenueByDay, "getRevenueByDay");
68
+ async function getPreviousPeriodRevenueByDay(range = "30d") {
69
+ if (range === "all")
70
+ return [];
71
+ const hours = {
72
+ "24h": 24,
73
+ "7d": 7 * 24,
74
+ "30d": 30 * 24,
75
+ "90d": 90 * 24
76
+ };
77
+ const periodMs = (hours[range] ?? 30 * 24) * 60 * 60 * 1e3;
78
+ const now = /* @__PURE__ */ new Date();
79
+ const periodStart = new Date(now.getTime() - periodMs);
80
+ const prevStart = new Date(periodStart.getTime() - periodMs);
81
+ const rows = await db.select({
82
+ date: sql`DATE(${purchases.createdAt})`.as("date"),
83
+ revenue: sum(purchases.totalAmount),
84
+ count: count()
85
+ }).from(purchases).where(and(paidPurchase(), gte(purchases.createdAt, prevStart), lte(purchases.createdAt, periodStart))).groupBy(sql`DATE(${purchases.createdAt})`).orderBy(sql`DATE(${purchases.createdAt})`);
86
+ return rows.map((r) => ({
87
+ date: r.date,
88
+ revenue: Number(r.revenue ?? 0),
89
+ count: r.count
90
+ }));
91
+ }
92
+ __name(getPreviousPeriodRevenueByDay, "getPreviousPeriodRevenueByDay");
93
+ async function getRevenueByProduct(range = "30d") {
94
+ const since = rangeToDate(range);
95
+ const conditions = [
96
+ paidPurchase()
97
+ ];
98
+ if (since)
99
+ conditions.push(gte(purchases.createdAt, since));
100
+ const rows = await db.select({
101
+ productId: purchases.productId,
102
+ productName: products.name,
103
+ revenue: sum(purchases.totalAmount),
104
+ count: count()
105
+ }).from(purchases).leftJoin(products, eq(purchases.productId, products.id)).where(and(...conditions)).groupBy(purchases.productId, products.name).orderBy(desc(sum(purchases.totalAmount)));
106
+ return rows.map((r) => ({
107
+ productId: r.productId,
108
+ productName: r.productName ?? "(unknown)",
109
+ revenue: Number(r.revenue ?? 0),
110
+ count: r.count
111
+ }));
112
+ }
113
+ __name(getRevenueByProduct, "getRevenueByProduct");
114
+ async function getRevenueByCountry(range = "30d") {
115
+ const since = rangeToDate(range);
116
+ const conditions = [
117
+ paidPurchase()
118
+ ];
119
+ if (since)
120
+ conditions.push(gte(purchases.createdAt, since));
121
+ const rows = await db.select({
122
+ country: purchases.country,
123
+ revenue: sum(purchases.totalAmount),
124
+ count: count()
125
+ }).from(purchases).where(and(...conditions)).groupBy(purchases.country).orderBy(desc(sum(purchases.totalAmount))).limit(20);
126
+ return rows.map((r) => ({
127
+ country: r.country ?? "(unknown)",
128
+ revenue: Number(r.revenue ?? 0),
129
+ count: r.count
130
+ }));
131
+ }
132
+ __name(getRevenueByCountry, "getRevenueByCountry");
133
+ async function getRecentPurchases(limit = 20, filter = "all", range = "all") {
134
+ const since = rangeToDate(range);
135
+ const conditions = [
136
+ paidPurchase()
137
+ ];
138
+ if (since)
139
+ conditions.push(gte(purchases.createdAt, since));
140
+ if (filter === "team") {
141
+ conditions.push(sql`${purchases.bulkCouponId} IS NOT NULL`);
142
+ const rows2 = await db.select({
143
+ id: purchases.id,
144
+ createdAt: purchases.createdAt,
145
+ totalAmount: purchases.totalAmount,
146
+ productName: products.name,
147
+ productId: purchases.productId,
148
+ country: purchases.country,
149
+ couponId: purchases.couponId,
150
+ userId: purchases.userId,
151
+ userName: users.name,
152
+ userEmail: users.email,
153
+ organizationId: purchases.organizationId,
154
+ seats: coupon.maxUses
155
+ }).from(purchases).leftJoin(products, eq(purchases.productId, products.id)).leftJoin(users, eq(purchases.userId, users.id)).leftJoin(coupon, eq(purchases.bulkCouponId, coupon.id)).where(and(...conditions, gt(coupon.maxUses, 1))).orderBy(desc(purchases.totalAmount)).limit(limit);
156
+ return rows2.map((r) => ({
157
+ id: r.id,
158
+ createdAt: r.createdAt,
159
+ totalAmount: Number(r.totalAmount),
160
+ productName: r.productName ?? "(unknown)",
161
+ productId: r.productId,
162
+ country: r.country,
163
+ couponId: r.couponId,
164
+ userName: r.userName ?? null,
165
+ userEmail: r.userEmail ?? null,
166
+ isTeam: true,
167
+ seats: r.seats ?? null
168
+ }));
169
+ }
170
+ if (filter === "individual") {
171
+ conditions.push(sql`${purchases.bulkCouponId} IS NULL`);
172
+ }
173
+ const rows = await db.query.purchases.findMany({
174
+ where: and(...conditions),
175
+ orderBy: [
176
+ desc(purchases.totalAmount)
177
+ ],
178
+ limit,
179
+ with: {
180
+ product: true,
181
+ user: true
182
+ }
183
+ });
184
+ return rows.map((r) => ({
185
+ id: r.id,
186
+ createdAt: r.createdAt,
187
+ totalAmount: Number(r.totalAmount),
188
+ productName: r.product?.name ?? "(unknown)",
189
+ productId: r.productId,
190
+ country: r.country,
191
+ couponId: r.couponId,
192
+ userName: r.user?.name ?? null,
193
+ userEmail: r.user?.email ?? null,
194
+ isTeam: r.organizationId != null,
195
+ seats: null
196
+ }));
197
+ }
198
+ __name(getRecentPurchases, "getRecentPurchases");
199
+ async function getAttributionSummary(range = "30d") {
200
+ const since = rangeToDate(range);
201
+ const conditions = [];
202
+ if (since)
203
+ conditions.push(gte(shortlinkAttribution.createdAt, since));
204
+ const rows = await db.select({
205
+ type: shortlinkAttribution.type,
206
+ count: count()
207
+ }).from(shortlinkAttribution).where(conditions.length > 0 ? and(...conditions) : void 0).groupBy(shortlinkAttribution.type);
208
+ return rows.map((r) => ({
209
+ type: r.type,
210
+ count: r.count
211
+ }));
212
+ }
213
+ __name(getAttributionSummary, "getAttributionSummary");
214
+ async function getShortlinkPerformance(range = "30d") {
215
+ const since = rangeToDate(range);
216
+ const clickConditions = [];
217
+ if (since)
218
+ clickConditions.push(gte(shortlinkClick.timestamp, since));
219
+ const rows = await db.select({
220
+ shortlinkId: shortlinkClick.shortlinkId,
221
+ slug: shortlink.slug,
222
+ url: shortlink.url,
223
+ clicks: count()
224
+ }).from(shortlinkClick).innerJoin(shortlink, eq(shortlinkClick.shortlinkId, shortlink.id)).where(clickConditions.length > 0 ? and(...clickConditions) : void 0).groupBy(shortlinkClick.shortlinkId, shortlink.slug, shortlink.url).orderBy(desc(count())).limit(20);
225
+ const attrConditions = [];
226
+ if (since)
227
+ attrConditions.push(gte(shortlinkAttribution.createdAt, since));
228
+ const attrRows = await db.select({
229
+ shortlinkId: shortlinkAttribution.shortlinkId,
230
+ type: shortlinkAttribution.type,
231
+ count: count()
232
+ }).from(shortlinkAttribution).where(attrConditions.length > 0 ? and(...attrConditions) : void 0).groupBy(shortlinkAttribution.shortlinkId, shortlinkAttribution.type);
233
+ const attrMap = /* @__PURE__ */ new Map();
234
+ for (const a of attrRows) {
235
+ const existing = attrMap.get(a.shortlinkId) ?? {
236
+ signups: 0,
237
+ purchases: 0
238
+ };
239
+ if (a.type === "signup")
240
+ existing.signups = a.count;
241
+ if (a.type === "purchase")
242
+ existing.purchases = a.count;
243
+ attrMap.set(a.shortlinkId, existing);
244
+ }
245
+ return rows.map((r) => {
246
+ const attr = attrMap.get(r.shortlinkId);
247
+ return {
248
+ shortlinkId: r.shortlinkId,
249
+ slug: r.slug,
250
+ url: r.url,
251
+ clicks: r.clicks,
252
+ signups: attr?.signups ?? 0,
253
+ purchases: attr?.purchases ?? 0
254
+ };
255
+ });
256
+ }
257
+ __name(getShortlinkPerformance, "getShortlinkPerformance");
258
+ async function getRevenueBySource(range = "30d") {
259
+ const since = rangeToDate(range);
260
+ const conditions = [
261
+ paidPurchase()
262
+ ];
263
+ if (since)
264
+ conditions.push(gte(purchases.createdAt, since));
265
+ const rows = await db.select({
266
+ source: sql`JSON_UNQUOTE(JSON_EXTRACT(${purchases.fields}, '$.utmSource'))`.as("source"),
267
+ medium: sql`JSON_UNQUOTE(JSON_EXTRACT(${purchases.fields}, '$.utmMedium'))`.as("medium"),
268
+ campaign: sql`JSON_UNQUOTE(JSON_EXTRACT(${purchases.fields}, '$.utmCampaign'))`.as("campaign"),
269
+ revenue: sum(purchases.totalAmount),
270
+ count: count()
271
+ }).from(purchases).where(and(...conditions)).groupBy(sql`JSON_UNQUOTE(JSON_EXTRACT(${purchases.fields}, '$.utmSource'))`, sql`JSON_UNQUOTE(JSON_EXTRACT(${purchases.fields}, '$.utmMedium'))`, sql`JSON_UNQUOTE(JSON_EXTRACT(${purchases.fields}, '$.utmCampaign'))`).orderBy(desc(sum(purchases.totalAmount)));
272
+ return rows.map((r) => ({
273
+ source: r.source ?? null,
274
+ medium: r.medium ?? null,
275
+ campaign: r.campaign ?? null,
276
+ revenue: Number(r.revenue ?? 0),
277
+ count: r.count
278
+ }));
279
+ }
280
+ __name(getRevenueBySource, "getRevenueBySource");
281
+ async function getConversionFunnel(range = "30d") {
282
+ const since = rangeToDate(range);
283
+ const userConditions = since ? [
284
+ gte(users.createdAt, since)
285
+ ] : [];
286
+ const purchaseConditions = [
287
+ paidPurchase()
288
+ ];
289
+ if (since)
290
+ purchaseConditions.push(gte(purchases.createdAt, since));
291
+ const [userCount] = await db.select({
292
+ total: count()
293
+ }).from(users).where(userConditions.length > 0 ? and(...userConditions) : void 0);
294
+ const [purchaseCount] = await db.select({
295
+ total: count()
296
+ }).from(purchases).where(and(...purchaseConditions));
297
+ const [attributedCount] = await db.select({
298
+ total: count()
299
+ }).from(purchases).where(and(...purchaseConditions, sql`(
300
+ JSON_UNQUOTE(JSON_EXTRACT(${purchases.fields}, '$.utmSource')) IS NOT NULL
301
+ OR JSON_UNQUOTE(JSON_EXTRACT(${purchases.fields}, '$.gaClientId')) IS NOT NULL
302
+ )`));
303
+ const totalSignups = userCount?.total ?? 0;
304
+ const totalPurchases = purchaseCount?.total ?? 0;
305
+ const attributedPurchases = attributedCount?.total ?? 0;
306
+ return {
307
+ totalSignups,
308
+ totalPurchases,
309
+ attributedPurchases,
310
+ conversionRate: totalSignups > 0 ? totalPurchases / totalSignups : 0,
311
+ attributionCoverage: totalPurchases > 0 ? attributedPurchases / totalPurchases : 0
312
+ };
313
+ }
314
+ __name(getConversionFunnel, "getConversionFunnel");
315
+ async function getAttributedRevenueSummary(range = "30d") {
316
+ const since = rangeToDate(range);
317
+ const conditions = [
318
+ paidPurchase()
319
+ ];
320
+ if (since)
321
+ conditions.push(gte(purchases.createdAt, since));
322
+ const [totals] = await db.select({
323
+ total: sum(purchases.totalAmount),
324
+ count: count()
325
+ }).from(purchases).where(and(...conditions));
326
+ const [attributed] = await db.select({
327
+ total: sum(purchases.totalAmount)
328
+ }).from(purchases).where(and(...conditions, sql`(
329
+ JSON_UNQUOTE(JSON_EXTRACT(${purchases.fields}, '$.utmSource')) IS NOT NULL
330
+ OR JSON_UNQUOTE(JSON_EXTRACT(${purchases.fields}, '$.gaClientId')) IS NOT NULL
331
+ )`));
332
+ const totalRevenue = Number(totals?.total ?? 0);
333
+ const attributedRevenue = Number(attributed?.total ?? 0);
334
+ const unattributedRevenue = totalRevenue - attributedRevenue;
335
+ const totalPurchases = totals?.count ?? 0;
336
+ return {
337
+ totalRevenue,
338
+ attributedRevenue,
339
+ unattributedRevenue,
340
+ attributionRate: totalRevenue > 0 ? attributedRevenue / totalRevenue : 0,
341
+ totalPurchases
342
+ };
343
+ }
344
+ __name(getAttributedRevenueSummary, "getAttributedRevenueSummary");
345
+ async function getContentPurchaseCorrelation(range = "30d", limit = 20) {
346
+ const since = rangeToDate(range);
347
+ const purchaseConditions = [
348
+ paidPurchase()
349
+ ];
350
+ if (since)
351
+ purchaseConditions.push(gte(purchases.createdAt, since));
352
+ const purchaserRows = await db.selectDistinct({
353
+ userId: purchases.userId
354
+ }).from(purchases).where(and(...purchaseConditions));
355
+ const purchaserIds = purchaserRows.map((r) => r.userId).filter((id) => id !== null);
356
+ if (purchaserIds.length === 0)
357
+ return [];
358
+ const rows = await db.select({
359
+ resourceId: resourceProgress.resourceId,
360
+ purchaserCount: count()
361
+ }).from(resourceProgress).where(sql`${resourceProgress.userId} IN (${sql.join(purchaserIds.map((id) => sql`${id}`), sql`, `)})`).groupBy(resourceProgress.resourceId).orderBy(desc(count())).limit(limit);
362
+ return rows.map((r) => ({
363
+ resourceId: r.resourceId,
364
+ purchaserCount: r.purchaserCount
365
+ }));
366
+ }
367
+ __name(getContentPurchaseCorrelation, "getContentPurchaseCorrelation");
368
+ async function traceAttribution(opts) {
369
+ const events = [];
370
+ let userId = null;
371
+ let userEmail = opts.email ?? null;
372
+ let userRecord = null;
373
+ if (opts.purchaseId) {
374
+ const [purchase] = await db.select({
375
+ userId: purchases.userId,
376
+ email: users.email
377
+ }).from(purchases).leftJoin(users, eq(purchases.userId, users.id)).where(eq(purchases.id, opts.purchaseId)).limit(1);
378
+ userId = purchase?.userId ?? null;
379
+ userEmail = purchase?.email ?? userEmail;
380
+ }
381
+ if (userEmail && !userId) {
382
+ const [u] = await db.select({
383
+ id: users.id
384
+ }).from(users).where(eq(users.email, userEmail)).limit(1);
385
+ userId = u?.id ?? null;
386
+ }
387
+ if (userId) {
388
+ const [u] = await db.select({
389
+ id: users.id,
390
+ email: users.email,
391
+ name: users.name,
392
+ createdAt: users.createdAt
393
+ }).from(users).where(eq(users.id, userId)).limit(1);
394
+ userRecord = u ? {
395
+ id: u.id,
396
+ email: u.email,
397
+ name: u.name ?? null,
398
+ createdAt: u.createdAt
399
+ } : null;
400
+ }
401
+ const attrConditions = [];
402
+ if (userId)
403
+ attrConditions.push(eq(shortlinkAttribution.userId, userId));
404
+ if (userEmail)
405
+ attrConditions.push(eq(shortlinkAttribution.email, userEmail));
406
+ if (attrConditions.length > 0) {
407
+ const attrs = await db.select({
408
+ type: shortlinkAttribution.type,
409
+ createdAt: shortlinkAttribution.createdAt,
410
+ metadata: shortlinkAttribution.metadata,
411
+ shortlinkId: shortlinkAttribution.shortlinkId,
412
+ slug: shortlink.slug,
413
+ url: shortlink.url
414
+ }).from(shortlinkAttribution).leftJoin(shortlink, eq(shortlinkAttribution.shortlinkId, shortlink.id)).where(sql`(${sql.join(attrConditions, sql` OR `)})`).orderBy(shortlinkAttribution.createdAt);
415
+ for (const attr of attrs) {
416
+ const clicks = await db.select({
417
+ timestamp: shortlinkClick.timestamp,
418
+ referrer: shortlinkClick.referrer,
419
+ country: shortlinkClick.country,
420
+ device: shortlinkClick.device
421
+ }).from(shortlinkClick).where(and(eq(shortlinkClick.shortlinkId, attr.shortlinkId), lte(shortlinkClick.timestamp, attr.createdAt))).orderBy(desc(shortlinkClick.timestamp)).limit(3);
422
+ for (const click of clicks) {
423
+ events.push({
424
+ type: "click",
425
+ timestamp: click.timestamp,
426
+ detail: {
427
+ shortlink: `/s/${attr.slug}`,
428
+ destination: attr.url,
429
+ referrer: click.referrer,
430
+ country: click.country,
431
+ device: click.device
432
+ }
433
+ });
434
+ }
435
+ events.push({
436
+ type: attr.type === "purchase" ? "purchase" : "signup",
437
+ timestamp: attr.createdAt,
438
+ detail: {
439
+ shortlink: `/s/${attr.slug}`,
440
+ metadata: attr.metadata ? JSON.parse(attr.metadata) : null
441
+ }
442
+ });
443
+ }
444
+ }
445
+ if (userId) {
446
+ const progress = await db.select({
447
+ resourceId: resourceProgress.resourceId,
448
+ completedAt: resourceProgress.completedAt,
449
+ createdAt: resourceProgress.createdAt
450
+ }).from(resourceProgress).where(eq(resourceProgress.userId, userId)).orderBy(resourceProgress.createdAt).limit(20);
451
+ for (const p of progress) {
452
+ events.push({
453
+ type: "progress",
454
+ timestamp: p.completedAt ?? p.createdAt,
455
+ detail: {
456
+ resourceId: p.resourceId
457
+ }
458
+ });
459
+ }
460
+ }
461
+ const purchaseConditions = [
462
+ paidPurchase()
463
+ ];
464
+ if (opts.purchaseId) {
465
+ purchaseConditions.push(eq(purchases.id, opts.purchaseId));
466
+ } else if (userId) {
467
+ purchaseConditions.push(eq(purchases.userId, userId));
468
+ } else {
469
+ return {
470
+ user: userRecord,
471
+ events: [],
472
+ purchases: []
473
+ };
474
+ }
475
+ const purchaseRows = await db.select({
476
+ id: purchases.id,
477
+ totalAmount: purchases.totalAmount,
478
+ productName: products.name,
479
+ createdAt: purchases.createdAt,
480
+ country: purchases.country,
481
+ fields: purchases.fields
482
+ }).from(purchases).leftJoin(products, eq(purchases.productId, products.id)).where(and(...purchaseConditions)).orderBy(purchases.createdAt);
483
+ const purchaseResults = purchaseRows.map((p) => {
484
+ const fields = p.fields ?? {};
485
+ events.push({
486
+ type: "purchase",
487
+ timestamp: p.createdAt,
488
+ detail: {
489
+ purchaseId: p.id,
490
+ amount: Number(p.totalAmount),
491
+ product: p.productName
492
+ }
493
+ });
494
+ return {
495
+ id: p.id,
496
+ totalAmount: Number(p.totalAmount),
497
+ productName: p.productName ?? "Unknown",
498
+ createdAt: p.createdAt,
499
+ country: p.country,
500
+ utmSource: fields.utmSource ?? null,
501
+ utmMedium: fields.utmMedium ?? null,
502
+ utmCampaign: fields.utmCampaign ?? null
503
+ };
504
+ });
505
+ events.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
506
+ return {
507
+ user: userRecord,
508
+ events,
509
+ purchases: purchaseResults
510
+ };
511
+ }
512
+ __name(traceAttribution, "traceAttribution");
513
+ return {
514
+ getRevenueSummary,
515
+ getRevenueByDay,
516
+ getPreviousPeriodRevenueByDay,
517
+ getRevenueByProduct,
518
+ getRevenueByCountry,
519
+ getRecentPurchases,
520
+ getAttributionSummary,
521
+ getShortlinkPerformance,
522
+ getRevenueBySource,
523
+ getConversionFunnel,
524
+ getAttributedRevenueSummary,
525
+ getContentPurchaseCorrelation,
526
+ traceAttribution
527
+ };
528
+ }
529
+ __name(createDatabaseProvider, "createDatabaseProvider");
530
+
531
+ // src/providers/ga4.ts
532
+ import { BetaAnalyticsDataClient } from "@google-analytics/data";
533
+ function createGA4Provider(config) {
534
+ const { propertyId, clientEmail, privateKey } = config;
535
+ let _client = null;
536
+ function getClient() {
537
+ if (!_client) {
538
+ _client = new BetaAnalyticsDataClient({
539
+ credentials: {
540
+ client_email: clientEmail,
541
+ private_key: privateKey.replace(/\\n/g, "\n")
542
+ }
543
+ });
544
+ }
545
+ return _client;
546
+ }
547
+ __name(getClient, "getClient");
548
+ function rangeToDateRange(range) {
549
+ const map = {
550
+ "24h": "1daysAgo",
551
+ "7d": "7daysAgo",
552
+ "30d": "30daysAgo",
553
+ "90d": "90daysAgo"
554
+ };
555
+ return {
556
+ startDate: map[range],
557
+ endDate: "today"
558
+ };
559
+ }
560
+ __name(rangeToDateRange, "rangeToDateRange");
561
+ async function getTrafficOverview(range = "30d") {
562
+ const client = getClient();
563
+ const [response] = await client.runReport({
564
+ property: `properties/${propertyId}`,
565
+ dateRanges: [
566
+ rangeToDateRange(range)
567
+ ],
568
+ metrics: [
569
+ {
570
+ name: "sessions"
571
+ },
572
+ {
573
+ name: "totalUsers"
574
+ },
575
+ {
576
+ name: "newUsers"
577
+ },
578
+ {
579
+ name: "screenPageViews"
580
+ },
581
+ {
582
+ name: "averageSessionDuration"
583
+ },
584
+ {
585
+ name: "bounceRate"
586
+ }
587
+ ]
588
+ });
589
+ const row = response?.rows?.[0];
590
+ if (!row) {
591
+ return {
592
+ sessions: 0,
593
+ totalUsers: 0,
594
+ newUsers: 0,
595
+ pageviews: 0,
596
+ avgSessionDuration: 0,
597
+ bounceRate: 0
598
+ };
599
+ }
600
+ return {
601
+ sessions: Number(row.metricValues?.[0]?.value ?? 0),
602
+ totalUsers: Number(row.metricValues?.[1]?.value ?? 0),
603
+ newUsers: Number(row.metricValues?.[2]?.value ?? 0),
604
+ pageviews: Number(row.metricValues?.[3]?.value ?? 0),
605
+ avgSessionDuration: Number(row.metricValues?.[4]?.value ?? 0),
606
+ bounceRate: Number(row.metricValues?.[5]?.value ?? 0)
607
+ };
608
+ }
609
+ __name(getTrafficOverview, "getTrafficOverview");
610
+ async function getTopPages(range = "30d", limit = 20) {
611
+ const client = getClient();
612
+ const [response] = await client.runReport({
613
+ property: `properties/${propertyId}`,
614
+ dateRanges: [
615
+ rangeToDateRange(range)
616
+ ],
617
+ dimensions: [
618
+ {
619
+ name: "pagePath"
620
+ }
621
+ ],
622
+ metrics: [
623
+ {
624
+ name: "screenPageViews"
625
+ },
626
+ {
627
+ name: "totalUsers"
628
+ },
629
+ {
630
+ name: "averageSessionDuration"
631
+ }
632
+ ],
633
+ orderBys: [
634
+ {
635
+ metric: {
636
+ metricName: "screenPageViews"
637
+ },
638
+ desc: true
639
+ }
640
+ ],
641
+ limit
642
+ });
643
+ return response?.rows?.map((row) => ({
644
+ path: row.dimensionValues?.[0]?.value ?? "",
645
+ pageviews: Number(row.metricValues?.[0]?.value ?? 0),
646
+ users: Number(row.metricValues?.[1]?.value ?? 0),
647
+ avgDuration: Number(row.metricValues?.[2]?.value ?? 0)
648
+ })) ?? [];
649
+ }
650
+ __name(getTopPages, "getTopPages");
651
+ async function getTrafficSources(range = "30d", limit = 15) {
652
+ const client = getClient();
653
+ const [response] = await client.runReport({
654
+ property: `properties/${propertyId}`,
655
+ dateRanges: [
656
+ rangeToDateRange(range)
657
+ ],
658
+ dimensions: [
659
+ {
660
+ name: "sessionSource"
661
+ },
662
+ {
663
+ name: "sessionMedium"
664
+ }
665
+ ],
666
+ metrics: [
667
+ {
668
+ name: "sessions"
669
+ },
670
+ {
671
+ name: "totalUsers"
672
+ }
673
+ ],
674
+ orderBys: [
675
+ {
676
+ metric: {
677
+ metricName: "sessions"
678
+ },
679
+ desc: true
680
+ }
681
+ ],
682
+ limit
683
+ });
684
+ return response?.rows?.map((row) => ({
685
+ source: row.dimensionValues?.[0]?.value ?? "(direct)",
686
+ medium: row.dimensionValues?.[1]?.value ?? "(none)",
687
+ sessions: Number(row.metricValues?.[0]?.value ?? 0),
688
+ users: Number(row.metricValues?.[1]?.value ?? 0)
689
+ })) ?? [];
690
+ }
691
+ __name(getTrafficSources, "getTrafficSources");
692
+ async function getSessionsByDay(range = "30d") {
693
+ const client = getClient();
694
+ const [response] = await client.runReport({
695
+ property: `properties/${propertyId}`,
696
+ dateRanges: [
697
+ rangeToDateRange(range)
698
+ ],
699
+ dimensions: [
700
+ {
701
+ name: "date"
702
+ }
703
+ ],
704
+ metrics: [
705
+ {
706
+ name: "sessions"
707
+ },
708
+ {
709
+ name: "totalUsers"
710
+ },
711
+ {
712
+ name: "screenPageViews"
713
+ }
714
+ ],
715
+ orderBys: [
716
+ {
717
+ dimension: {
718
+ dimensionName: "date"
719
+ },
720
+ desc: false
721
+ }
722
+ ]
723
+ });
724
+ return response?.rows?.map((row) => {
725
+ const raw = row.dimensionValues?.[0]?.value ?? "";
726
+ const date = `${raw.slice(0, 4)}-${raw.slice(4, 6)}-${raw.slice(6, 8)}`;
727
+ return {
728
+ date,
729
+ sessions: Number(row.metricValues?.[0]?.value ?? 0),
730
+ users: Number(row.metricValues?.[1]?.value ?? 0),
731
+ pageviews: Number(row.metricValues?.[2]?.value ?? 0)
732
+ };
733
+ }) ?? [];
734
+ }
735
+ __name(getSessionsByDay, "getSessionsByDay");
736
+ return {
737
+ getTrafficOverview,
738
+ getTopPages,
739
+ getTrafficSources,
740
+ getSessionsByDay
741
+ };
742
+ }
743
+ __name(createGA4Provider, "createGA4Provider");
744
+
745
+ // src/providers/derived.ts
746
+ function createDerivedProvider(deps) {
747
+ const { database, ga4 } = deps;
748
+ function toGA4Range(range) {
749
+ if (range === "all")
750
+ return "90d";
751
+ return range;
752
+ }
753
+ __name(toGA4Range, "toGA4Range");
754
+ async function getTrafficRevenueCorrelation(range) {
755
+ const [traffic, revenue] = await Promise.all([
756
+ ga4.getSessionsByDay(toGA4Range(range)),
757
+ database.getRevenueByDay(range)
758
+ ]);
759
+ return {
760
+ traffic,
761
+ revenue
762
+ };
763
+ }
764
+ __name(getTrafficRevenueCorrelation, "getTrafficRevenueCorrelation");
765
+ return {
766
+ getTrafficRevenueCorrelation
767
+ };
768
+ }
769
+ __name(createDerivedProvider, "createDerivedProvider");
770
+
771
+ // src/providers/mux.ts
772
+ import { sql as sql2 } from "drizzle-orm";
773
+ function createMuxProvider(config, dbDeps) {
774
+ const MUX_DATA_BASE = "https://api.mux.com/data/v1";
775
+ function getAuthHeader() {
776
+ return `Basic ${Buffer.from(`${config.tokenId}:${config.tokenSecret}`).toString("base64")}`;
777
+ }
778
+ __name(getAuthHeader, "getAuthHeader");
779
+ async function muxDataFetch(path, params) {
780
+ const url = new URL(`${MUX_DATA_BASE}${path}`);
781
+ if (params) {
782
+ for (const [key, value] of Object.entries(params)) {
783
+ if (Array.isArray(value)) {
784
+ for (const v of value) {
785
+ url.searchParams.append(key, v);
786
+ }
787
+ } else {
788
+ url.searchParams.set(key, value);
789
+ }
790
+ }
791
+ }
792
+ const response = await fetch(url.toString(), {
793
+ headers: {
794
+ Authorization: getAuthHeader(),
795
+ "Content-Type": "application/json"
796
+ },
797
+ next: {
798
+ revalidate: 300
799
+ }
800
+ });
801
+ if (!response.ok) {
802
+ throw new Error(`Mux Data API error: ${response.status} ${response.statusText}`);
803
+ }
804
+ return response.json();
805
+ }
806
+ __name(muxDataFetch, "muxDataFetch");
807
+ async function getComparisonTotals(timeRange = "30:days") {
808
+ const resp = await muxDataFetch("/metrics/comparison", {
809
+ "timeframe[]": timeRange
810
+ });
811
+ const totals = resp.data.find((d) => d.name === "totals");
812
+ return {
813
+ uniqueViewers: totals?.unique_viewers ?? 0,
814
+ viewCount: totals?.view_count ?? 0,
815
+ watchTimeMs: totals?.watch_time ?? 0
816
+ };
817
+ }
818
+ __name(getComparisonTotals, "getComparisonTotals");
819
+ async function getViewsOverall(timeRange = "30:days") {
820
+ return muxDataFetch("/metrics/views/overall", {
821
+ "timeframe[]": timeRange
822
+ });
823
+ }
824
+ __name(getViewsOverall, "getViewsOverall");
825
+ async function getViewerExperienceScore(timeRange = "30:days") {
826
+ return muxDataFetch("/metrics/viewer_experience_score/overall", {
827
+ "timeframe[]": timeRange
828
+ });
829
+ }
830
+ __name(getViewerExperienceScore, "getViewerExperienceScore");
831
+ async function getViewsTimeseries(timeRange = "30:days") {
832
+ return muxDataFetch("/metrics/views/timeseries", {
833
+ "timeframe[]": timeRange,
834
+ group_by: "day"
835
+ });
836
+ }
837
+ __name(getViewsTimeseries, "getViewsTimeseries");
838
+ async function getWatchTimeTimeseries(timeRange = "30:days") {
839
+ return muxDataFetch("/metrics/playing_time/timeseries", {
840
+ "timeframe[]": timeRange,
841
+ group_by: "day"
842
+ });
843
+ }
844
+ __name(getWatchTimeTimeseries, "getWatchTimeTimeseries");
845
+ async function getVideoBreakdown(timeRange = "30:days", limit = 25) {
846
+ return muxDataFetch("/metrics/views/breakdown", {
847
+ "timeframe[]": timeRange,
848
+ group_by: "video_title",
849
+ order_by: "views",
850
+ order_direction: "desc",
851
+ limit: String(limit)
852
+ });
853
+ }
854
+ __name(getVideoBreakdown, "getVideoBreakdown");
855
+ async function getVideoBreakdownForRange(timeRange, limit = 50) {
856
+ return muxDataFetch("/metrics/views/breakdown", {
857
+ "timeframe[]": timeRange,
858
+ group_by: "video_title",
859
+ order_by: "views",
860
+ order_direction: "desc",
861
+ limit: String(limit)
862
+ });
863
+ }
864
+ __name(getVideoBreakdownForRange, "getVideoBreakdownForRange");
865
+ async function getCountryBreakdown(timeRange = "30:days", limit = 10) {
866
+ return muxDataFetch("/metrics/views/breakdown", {
867
+ "timeframe[]": timeRange,
868
+ group_by: "country",
869
+ order_by: "views",
870
+ order_direction: "desc",
871
+ limit: String(limit)
872
+ });
873
+ }
874
+ __name(getCountryBreakdown, "getCountryBreakdown");
875
+ async function getVideoDetailBreakdowns(videoTitle, timeRange = "30:days") {
876
+ const filter = `video_title:${videoTitle}`;
877
+ const [countries, timeseries] = await Promise.all([
878
+ muxDataFetch("/metrics/views/breakdown", {
879
+ "timeframe[]": timeRange,
880
+ group_by: "country",
881
+ order_by: "views",
882
+ order_direction: "desc",
883
+ limit: "8",
884
+ "filters[]": filter
885
+ }),
886
+ muxDataFetch("/metrics/views/timeseries", {
887
+ "timeframe[]": timeRange,
888
+ group_by: "day",
889
+ "filters[]": filter
890
+ })
891
+ ]);
892
+ return {
893
+ countries: countries.data.map((c) => ({
894
+ country: c.field,
895
+ views: c.views,
896
+ watchTimeMs: c.total_watch_time
897
+ })),
898
+ timeseries: timeseries.data.map(([date, value]) => ({
899
+ date,
900
+ views: value ?? 0
901
+ }))
902
+ };
903
+ }
904
+ __name(getVideoDetailBreakdowns, "getVideoDetailBreakdowns");
905
+ async function getVideoDashboardData(timeRange = "30:days") {
906
+ const [views, experience, comparison, watchTime, videos, countries] = await Promise.all([
907
+ getViewsOverall(timeRange),
908
+ getViewerExperienceScore(timeRange),
909
+ getComparisonTotals(timeRange),
910
+ getWatchTimeTimeseries(timeRange),
911
+ getVideoBreakdown(timeRange, 50),
912
+ getCountryBreakdown(timeRange, 15)
913
+ ]);
914
+ return {
915
+ overview: {
916
+ totalViews: views.data.total_views,
917
+ uniqueViewers: comparison.uniqueViewers,
918
+ totalWatchTimeMs: views.data.total_watch_time,
919
+ totalPlayingTimeMs: views.data.total_playing_time,
920
+ viewerExperienceScore: experience.data.value,
921
+ globalExperienceScore: experience.data.global_value
922
+ },
923
+ watchTimeSeries: watchTime.data.map(([date, totalMs]) => ({
924
+ date,
925
+ // Mux playing_time timeseries: value is total playing time in ms
926
+ watchTimeMs: totalMs ?? 0
927
+ })),
928
+ topVideos: videos.data.filter((v) => v.field !== "").map((v) => ({
929
+ title: v.field,
930
+ views: v.views,
931
+ watchTimeMs: v.total_watch_time,
932
+ playingTimeMs: v.total_playing_time
933
+ })),
934
+ countries: countries.data.map((c) => ({
935
+ country: c.field,
936
+ views: c.views,
937
+ watchTimeMs: c.total_watch_time
938
+ }))
939
+ };
940
+ }
941
+ __name(getVideoDashboardData, "getVideoDashboardData");
942
+ async function getVideoThumbnails(timeRange = "30:days", limit = 10) {
943
+ if (!dbDeps) {
944
+ throw new Error("getVideoThumbnails requires dbDeps (db + contentResource) to be provided to createMuxProvider");
945
+ }
946
+ const { db, contentResource } = dbDeps;
947
+ const [byId, byTitle] = await Promise.all([
948
+ muxDataFetch("/metrics/views/breakdown", {
949
+ "timeframe[]": timeRange,
950
+ group_by: "video_id",
951
+ order_by: "views",
952
+ order_direction: "desc",
953
+ limit: String(limit)
954
+ }),
955
+ muxDataFetch("/metrics/views/breakdown", {
956
+ "timeframe[]": timeRange,
957
+ group_by: "video_title",
958
+ order_by: "views",
959
+ order_direction: "desc",
960
+ limit: String(limit)
961
+ })
962
+ ]);
963
+ const videoIds = byId.data.filter((v) => v.field && v.field !== "").map((v) => v.field);
964
+ if (videoIds.length === 0)
965
+ return {};
966
+ const rows = await db.select({
967
+ id: contentResource.id,
968
+ playbackId: sql2`JSON_UNQUOTE(JSON_EXTRACT(${contentResource.fields}, '$.muxPlaybackId'))`.as("playbackId")
969
+ }).from(contentResource).where(sql2`${contentResource.id} IN (${sql2.join(videoIds.map((id) => sql2`${id}`), sql2`, `)})`);
970
+ const idToPlayback = /* @__PURE__ */ new Map();
971
+ for (const r of rows) {
972
+ if (r.playbackId && r.playbackId !== "null") {
973
+ idToPlayback.set(r.id, r.playbackId);
974
+ }
975
+ }
976
+ const result = {};
977
+ for (let i = 0; i < Math.min(byId.data.length, byTitle.data.length); i++) {
978
+ const videoId = byId.data[i]?.field;
979
+ const title = byTitle.data[i]?.field;
980
+ if (!videoId || !title)
981
+ continue;
982
+ const playbackId = idToPlayback.get(videoId);
983
+ if (playbackId) {
984
+ result[title] = `https://image.mux.com/${playbackId}/thumbnail.jpg?width=240&height=135&fit_mode=smartcrop`;
985
+ }
986
+ }
987
+ return result;
988
+ }
989
+ __name(getVideoThumbnails, "getVideoThumbnails");
990
+ return {
991
+ getComparisonTotals,
992
+ getViewsOverall,
993
+ getViewerExperienceScore,
994
+ getViewsTimeseries,
995
+ getWatchTimeTimeseries,
996
+ getVideoBreakdown,
997
+ getVideoBreakdownForRange,
998
+ getCountryBreakdown,
999
+ getVideoDetailBreakdowns,
1000
+ getVideoDashboardData,
1001
+ getVideoThumbnails
1002
+ };
1003
+ }
1004
+ __name(createMuxProvider, "createMuxProvider");
1005
+
1006
+ // src/providers/survey.ts
1007
+ import { and as and2, count as count2, eq as eq2, sql as sql3 } from "drizzle-orm";
1008
+ function normalizeRespondentKey(row) {
1009
+ if (row.respondentKey)
1010
+ return row.respondentKey;
1011
+ if (row.userId)
1012
+ return `user:${row.userId}`;
1013
+ if (row.emailListSubscriberId) {
1014
+ return `subscriber:${row.emailListSubscriberId}`;
1015
+ }
1016
+ if (row.surveySessionId)
1017
+ return `session:${row.surveySessionId}`;
1018
+ return null;
1019
+ }
1020
+ __name(normalizeRespondentKey, "normalizeRespondentKey");
1021
+ function getRowTimestamp(row) {
1022
+ const date = row.updatedAt ?? row.createdAt;
1023
+ return date instanceof Date ? date.getTime() : 0;
1024
+ }
1025
+ __name(getRowTimestamp, "getRowTimestamp");
1026
+ function sortByNewest(rows) {
1027
+ return [
1028
+ ...rows
1029
+ ].sort((a, b) => getRowTimestamp(b) - getRowTimestamp(a));
1030
+ }
1031
+ __name(sortByNewest, "sortByNewest");
1032
+ function createSurveyProvider(db, schema) {
1033
+ const { contentResource, contentResourceResource, questionResponse } = schema;
1034
+ function rangeToInterval(range) {
1035
+ switch (range) {
1036
+ case "24h":
1037
+ return "1 DAY";
1038
+ case "7d":
1039
+ return "7 DAY";
1040
+ case "30d":
1041
+ return "30 DAY";
1042
+ case "90d":
1043
+ return "90 DAY";
1044
+ case "all":
1045
+ return "3650 DAY";
1046
+ }
1047
+ }
1048
+ __name(rangeToInterval, "rangeToInterval");
1049
+ function rangeWhere(range, column) {
1050
+ return sql3`${column} >= DATE_SUB(NOW(), INTERVAL ${sql3.raw(rangeToInterval(range))})`;
1051
+ }
1052
+ __name(rangeWhere, "rangeWhere");
1053
+ async function fetchCanonicalRows(range) {
1054
+ const { users } = schema;
1055
+ const rawRows = users ? await db.select({
1056
+ responseId: questionResponse.id,
1057
+ surveyId: questionResponse.surveyId,
1058
+ surveyTitle: sql3`JSON_UNQUOTE(JSON_EXTRACT(survey_cr.fields, '$.title'))`,
1059
+ surveySlug: sql3`JSON_UNQUOTE(JSON_EXTRACT(survey_cr.fields, '$.slug'))`,
1060
+ questionId: questionResponse.questionId,
1061
+ questionText: sql3`JSON_UNQUOTE(JSON_EXTRACT(question_cr.fields, '$.question'))`,
1062
+ questionType: sql3`JSON_UNQUOTE(JSON_EXTRACT(question_cr.fields, '$.type'))`,
1063
+ answer: sql3`JSON_UNQUOTE(JSON_EXTRACT(${questionResponse.fields}, '$.answer'))`,
1064
+ respondentKey: questionResponse.respondentKey,
1065
+ surveySessionId: questionResponse.surveySessionId,
1066
+ userId: questionResponse.userId,
1067
+ userEmail: users.email,
1068
+ emailListSubscriberId: questionResponse.emailListSubscriberId,
1069
+ createdAt: questionResponse.createdAt,
1070
+ updatedAt: questionResponse.updatedAt
1071
+ }).from(questionResponse).leftJoin(sql3`${contentResource} AS survey_cr`, sql3`survey_cr.id = ${questionResponse.surveyId}`).leftJoin(sql3`${contentResource} AS question_cr`, sql3`question_cr.id = ${questionResponse.questionId}`).leftJoin(users, eq2(questionResponse.userId, users.id)).where(rangeWhere(range, questionResponse.createdAt)) : await db.select({
1072
+ responseId: questionResponse.id,
1073
+ surveyId: questionResponse.surveyId,
1074
+ surveyTitle: sql3`JSON_UNQUOTE(JSON_EXTRACT(survey_cr.fields, '$.title'))`,
1075
+ surveySlug: sql3`JSON_UNQUOTE(JSON_EXTRACT(survey_cr.fields, '$.slug'))`,
1076
+ questionId: questionResponse.questionId,
1077
+ questionText: sql3`JSON_UNQUOTE(JSON_EXTRACT(question_cr.fields, '$.question'))`,
1078
+ questionType: sql3`JSON_UNQUOTE(JSON_EXTRACT(question_cr.fields, '$.type'))`,
1079
+ answer: sql3`JSON_UNQUOTE(JSON_EXTRACT(${questionResponse.fields}, '$.answer'))`,
1080
+ respondentKey: questionResponse.respondentKey,
1081
+ surveySessionId: questionResponse.surveySessionId,
1082
+ userId: questionResponse.userId,
1083
+ userEmail: sql3`NULL`,
1084
+ emailListSubscriberId: questionResponse.emailListSubscriberId,
1085
+ createdAt: questionResponse.createdAt,
1086
+ updatedAt: questionResponse.updatedAt
1087
+ }).from(questionResponse).leftJoin(sql3`${contentResource} AS survey_cr`, sql3`survey_cr.id = ${questionResponse.surveyId}`).leftJoin(sql3`${contentResource} AS question_cr`, sql3`question_cr.id = ${questionResponse.questionId}`).where(rangeWhere(range, questionResponse.createdAt));
1088
+ const latestByAnswer = /* @__PURE__ */ new Map();
1089
+ for (const row of rawRows) {
1090
+ const respondentKey = normalizeRespondentKey(row);
1091
+ if (!respondentKey)
1092
+ continue;
1093
+ const dedupeKey = `${row.surveyId}::${row.questionId}::${respondentKey}`;
1094
+ const current = latestByAnswer.get(dedupeKey);
1095
+ if (!current || getRowTimestamp(row) >= getRowTimestamp(current)) {
1096
+ latestByAnswer.set(dedupeKey, {
1097
+ ...row,
1098
+ respondentKey
1099
+ });
1100
+ }
1101
+ }
1102
+ return sortByNewest(Array.from(latestByAnswer.values()));
1103
+ }
1104
+ __name(fetchCanonicalRows, "fetchCanonicalRows");
1105
+ async function getSurveySummary(range = "30d") {
1106
+ const [surveyCount] = await db.select({
1107
+ total: count2()
1108
+ }).from(contentResource).where(eq2(contentResource.type, "survey"));
1109
+ const canonicalRows = await fetchCanonicalRows(range);
1110
+ const respondentKeys = new Set(canonicalRows.map((row) => row.respondentKey));
1111
+ const totalSurveys = surveyCount?.total ?? 0;
1112
+ const totalResponses = canonicalRows.length;
1113
+ return {
1114
+ totalSurveys,
1115
+ totalResponses,
1116
+ uniqueRespondents: respondentKeys.size,
1117
+ avgResponsesPerSurvey: totalSurveys > 0 ? totalResponses / totalSurveys : 0
1118
+ };
1119
+ }
1120
+ __name(getSurveySummary, "getSurveySummary");
1121
+ async function getSurveyList(range = "30d") {
1122
+ const canonicalRows = await fetchCanonicalRows(range);
1123
+ const responsesBySurvey = /* @__PURE__ */ new Map();
1124
+ for (const row of canonicalRows) {
1125
+ const current = responsesBySurvey.get(row.surveyId) ?? {
1126
+ responses: 0,
1127
+ respondents: /* @__PURE__ */ new Set()
1128
+ };
1129
+ current.responses += 1;
1130
+ current.respondents.add(row.respondentKey);
1131
+ responsesBySurvey.set(row.surveyId, current);
1132
+ }
1133
+ const surveys = await db.select({
1134
+ surveyId: contentResource.id,
1135
+ surveyTitle: sql3`JSON_UNQUOTE(JSON_EXTRACT(${contentResource.fields}, '$.title'))`,
1136
+ surveySlug: sql3`JSON_UNQUOTE(JSON_EXTRACT(${contentResource.fields}, '$.slug'))`
1137
+ }).from(contentResource).where(eq2(contentResource.type, "survey"));
1138
+ const questionCounts = await db.select({
1139
+ surveyId: contentResourceResource.resourceOfId,
1140
+ questionCount: count2()
1141
+ }).from(contentResourceResource).innerJoin(contentResource, and2(eq2(contentResourceResource.resourceId, contentResource.id), eq2(contentResource.type, "question"))).groupBy(contentResourceResource.resourceOfId);
1142
+ const questionCountMap = new Map(questionCounts.map((qc) => [
1143
+ qc.surveyId,
1144
+ qc.questionCount
1145
+ ]));
1146
+ return surveys.map((s) => {
1147
+ const counts = responsesBySurvey.get(s.surveyId);
1148
+ return {
1149
+ surveyId: s.surveyId,
1150
+ surveyTitle: s.surveyTitle ?? "",
1151
+ surveySlug: s.surveySlug ?? "",
1152
+ responses: counts?.responses ?? 0,
1153
+ uniqueRespondents: counts?.respondents.size ?? 0,
1154
+ questionCount: questionCountMap.get(s.surveyId) ?? 0
1155
+ };
1156
+ }).sort((a, b) => b.responses - a.responses);
1157
+ }
1158
+ __name(getSurveyList, "getSurveyList");
1159
+ async function getSurveyResponsesByDay(range = "30d") {
1160
+ const canonicalRows = await fetchCanonicalRows(range);
1161
+ const grouped = /* @__PURE__ */ new Map();
1162
+ for (const row of canonicalRows) {
1163
+ if (!(row.createdAt instanceof Date))
1164
+ continue;
1165
+ const date = row.createdAt.toISOString().slice(0, 10);
1166
+ grouped.set(date, (grouped.get(date) ?? 0) + 1);
1167
+ }
1168
+ return Array.from(grouped.entries()).sort((entryA, entryB) => entryA[0].localeCompare(entryB[0])).map(([date, responses]) => ({
1169
+ date,
1170
+ responses
1171
+ }));
1172
+ }
1173
+ __name(getSurveyResponsesByDay, "getSurveyResponsesByDay");
1174
+ async function getSurveyQuestionBreakdown(range = "30d", limit = 20) {
1175
+ const canonicalRows = await fetchCanonicalRows(range);
1176
+ const grouped = /* @__PURE__ */ new Map();
1177
+ for (const row of canonicalRows) {
1178
+ const current = grouped.get(row.questionId) ?? {
1179
+ questionId: row.questionId,
1180
+ question: row.questionText ?? "",
1181
+ type: row.questionType ?? null,
1182
+ responses: 0,
1183
+ respondents: /* @__PURE__ */ new Set(),
1184
+ answers: /* @__PURE__ */ new Map()
1185
+ };
1186
+ current.responses += 1;
1187
+ current.respondents.add(row.respondentKey);
1188
+ const answer = row.answer ?? "(no answer)";
1189
+ current.answers.set(answer, (current.answers.get(answer) ?? 0) + 1);
1190
+ grouped.set(row.questionId, current);
1191
+ }
1192
+ return Array.from(grouped.values()).sort((a, b) => b.responses - a.responses).slice(0, limit).map((entry) => ({
1193
+ questionId: entry.questionId,
1194
+ question: entry.question,
1195
+ type: entry.type,
1196
+ responses: entry.responses,
1197
+ uniqueRespondents: entry.respondents.size,
1198
+ answerDistribution: Array.from(entry.answers.entries()).sort((a, b) => b[1] - a[1]).map(([answer, count3]) => ({
1199
+ answer,
1200
+ count: count3
1201
+ }))
1202
+ }));
1203
+ }
1204
+ __name(getSurveyQuestionBreakdown, "getSurveyQuestionBreakdown");
1205
+ async function getSurveyResponses(range = "30d", limit = 100) {
1206
+ const canonicalRows = await fetchCanonicalRows(range);
1207
+ return canonicalRows.slice(0, limit).map((row) => ({
1208
+ responseId: row.responseId,
1209
+ surveyId: row.surveyId,
1210
+ surveyTitle: row.surveyTitle ?? "",
1211
+ surveySlug: row.surveySlug ?? "",
1212
+ questionId: row.questionId,
1213
+ questionText: row.questionText ?? "",
1214
+ questionType: row.questionType ?? null,
1215
+ answer: row.answer ?? "",
1216
+ userId: row.userId ?? null,
1217
+ userEmail: row.userEmail ?? null,
1218
+ emailListSubscriberId: row.emailListSubscriberId ?? null,
1219
+ createdAt: row.createdAt ? String(row.createdAt) : ""
1220
+ }));
1221
+ }
1222
+ __name(getSurveyResponses, "getSurveyResponses");
1223
+ return {
1224
+ getSurveySummary,
1225
+ getSurveyList,
1226
+ getSurveyResponsesByDay,
1227
+ getSurveyQuestionBreakdown,
1228
+ getSurveyResponses
1229
+ };
1230
+ }
1231
+ __name(createSurveyProvider, "createSurveyProvider");
1232
+ export {
1233
+ createDatabaseProvider,
1234
+ createDerivedProvider,
1235
+ createGA4Provider,
1236
+ createMuxProvider,
1237
+ createSurveyProvider
1238
+ };
1239
+ //# sourceMappingURL=index.js.map