@carlonicora/nextjs-jsonapi 3.24.2 → 3.26.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 (220) hide show
  1. package/dist/{AssistantMessageInterface-1Kbz8RVs.d.ts → AssistantMessageInterface-CubxuBIJ.d.ts} +1 -1
  2. package/dist/{AssistantMessageInterface-CoxJyuFM.d.mts → AssistantMessageInterface-sHjI2zPN.d.mts} +1 -1
  3. package/dist/{BlockNoteEditor-35D4R7HJ.js → BlockNoteEditor-4EIKMNY3.js} +31 -21
  4. package/dist/BlockNoteEditor-4EIKMNY3.js.map +1 -0
  5. package/dist/{BlockNoteEditor-65HR6ULV.mjs → BlockNoteEditor-AXYVDDXM.mjs} +16 -6
  6. package/dist/{BlockNoteEditor-65HR6ULV.mjs.map → BlockNoteEditor-AXYVDDXM.mjs.map} +1 -1
  7. package/dist/{BlockNoteViewer-QDXDVYEM.js → BlockNoteViewer-7BFNTN4E.js} +4 -4
  8. package/dist/{BlockNoteViewer-QDXDVYEM.js.map → BlockNoteViewer-7BFNTN4E.js.map} +1 -1
  9. package/dist/{BlockNoteViewer-KDKGUIG5.mjs → BlockNoteViewer-KRTAFTUX.mjs} +3 -3
  10. package/dist/{HandbookSectionInterface-OePKPbqN.d.ts → HandbookSectionInterface-Ck1wZJ81.d.ts} +1 -1
  11. package/dist/{HandbookSectionInterface-BYsmnPEz.d.mts → HandbookSectionInterface-DgYH--Ik.d.mts} +1 -1
  12. package/dist/JsonApiRequest-TE5WD3WS.js +26 -0
  13. package/dist/{JsonApiRequest-G625QAH5.js.map → JsonApiRequest-TE5WD3WS.js.map} +1 -1
  14. package/dist/{JsonApiRequest-RRMNV7YE.mjs → JsonApiRequest-UHOWNI3O.mjs} +2 -2
  15. package/dist/analytics.modules-CfczU12P.d.ts +25 -0
  16. package/dist/analytics.modules-pzGwMASI.d.mts +25 -0
  17. package/dist/{auth.interface-BC1jp_ni.d.ts → auth.interface-BzI4onI_.d.ts} +1 -1
  18. package/dist/{auth.interface-dR2-6gPM.d.mts → auth.interface-Df4vXbNz.d.mts} +1 -1
  19. package/dist/billing/index.js +402 -402
  20. package/dist/billing/index.mjs +4 -4
  21. package/dist/{chunk-YXRVPEEJ.js → chunk-26MQIJNB.js} +1 -1
  22. package/dist/{chunk-YXRVPEEJ.js.map → chunk-26MQIJNB.js.map} +1 -1
  23. package/dist/{chunk-T76WLDE5.mjs → chunk-37DMFVLN.mjs} +1 -1
  24. package/dist/{chunk-3AR4KA5D.js → chunk-4Z45NYPR.js} +13 -13
  25. package/dist/{chunk-3AR4KA5D.js.map → chunk-4Z45NYPR.js.map} +1 -1
  26. package/dist/chunk-7B4E42R5.mjs +280 -0
  27. package/dist/chunk-7B4E42R5.mjs.map +1 -0
  28. package/dist/{chunk-2XCUAHQT.js → chunk-ABVTYQIJ.js} +1563 -1336
  29. package/dist/chunk-ABVTYQIJ.js.map +1 -0
  30. package/dist/{chunk-U7QVISQS.js → chunk-CAKOFJZD.js} +450 -40
  31. package/dist/chunk-CAKOFJZD.js.map +1 -0
  32. package/dist/{chunk-IBVKPLHT.mjs → chunk-E3I4MT53.mjs} +422 -12
  33. package/dist/chunk-E3I4MT53.mjs.map +1 -0
  34. package/dist/chunk-GHFMKH3M.js +280 -0
  35. package/dist/chunk-GHFMKH3M.js.map +1 -0
  36. package/dist/chunk-GKDHYB2R.js +110 -0
  37. package/dist/chunk-GKDHYB2R.js.map +1 -0
  38. package/dist/{chunk-ZJI5ZZW5.mjs → chunk-Q6J2ICKO.mjs} +3 -3
  39. package/dist/{chunk-4O7R2Z2O.mjs → chunk-QWZAGQWJ.mjs} +2175 -1948
  40. package/dist/chunk-QWZAGQWJ.mjs.map +1 -0
  41. package/dist/chunk-U65JPKO3.mjs +110 -0
  42. package/dist/chunk-U65JPKO3.mjs.map +1 -0
  43. package/dist/client/index.d.mts +6 -5
  44. package/dist/client/index.d.ts +6 -5
  45. package/dist/client/index.js +5 -5
  46. package/dist/client/index.js.map +1 -1
  47. package/dist/client/index.mjs +4 -4
  48. package/dist/components/index.d.mts +33 -10
  49. package/dist/components/index.d.ts +33 -10
  50. package/dist/components/index.js +7 -5
  51. package/dist/components/index.js.map +1 -1
  52. package/dist/components/index.mjs +6 -4
  53. package/dist/{config-B6UB7j69.d.ts → config-3N1LSrcC.d.ts} +1 -1
  54. package/dist/{config-Cd5xTEfZ.d.ts → config-AVtuvfM1.d.ts} +1 -1
  55. package/dist/{config-n-ZpPmOL.d.mts → config-BSKTYDoh.d.mts} +1 -1
  56. package/dist/{config-YyhFHxkU.d.mts → config-BmufyEj6.d.mts} +1 -1
  57. package/dist/contexts/index.d.mts +5 -4
  58. package/dist/contexts/index.d.ts +5 -4
  59. package/dist/contexts/index.js +5 -5
  60. package/dist/contexts/index.mjs +4 -4
  61. package/dist/core/index.d.mts +31 -14
  62. package/dist/core/index.d.ts +31 -14
  63. package/dist/core/index.js +9 -5
  64. package/dist/core/index.js.map +1 -1
  65. package/dist/core/index.mjs +8 -4
  66. package/dist/features/analytics/index.css +30 -0
  67. package/dist/features/analytics/index.css.map +1 -0
  68. package/dist/features/analytics/index.d.mts +508 -0
  69. package/dist/features/analytics/index.d.ts +508 -0
  70. package/dist/features/analytics/index.js +1166 -0
  71. package/dist/features/analytics/index.js.map +1 -0
  72. package/dist/features/analytics/index.mjs +1166 -0
  73. package/dist/features/analytics/index.mjs.map +1 -0
  74. package/dist/features/analytics/server.d.mts +28 -0
  75. package/dist/features/analytics/server.d.ts +28 -0
  76. package/dist/features/analytics/server.js +41 -0
  77. package/dist/features/analytics/server.js.map +1 -0
  78. package/dist/features/analytics/server.mjs +41 -0
  79. package/dist/features/analytics/server.mjs.map +1 -0
  80. package/dist/features/help/index.d.mts +2 -2
  81. package/dist/features/help/index.d.ts +2 -2
  82. package/dist/features/help/index.js +43 -43
  83. package/dist/features/help/index.js.map +1 -1
  84. package/dist/features/help/index.mjs +9 -9
  85. package/dist/features/help/index.mjs.map +1 -1
  86. package/dist/features/tokenusage/index.d.mts +2 -2
  87. package/dist/features/tokenusage/index.d.ts +2 -2
  88. package/dist/features/tokenusage/index.js +141 -223
  89. package/dist/features/tokenusage/index.js.map +1 -1
  90. package/dist/features/tokenusage/index.mjs +28 -110
  91. package/dist/features/tokenusage/index.mjs.map +1 -1
  92. package/dist/index.d.mts +11 -9
  93. package/dist/index.d.ts +11 -9
  94. package/dist/index.js +10 -6
  95. package/dist/index.js.map +1 -1
  96. package/dist/index.mjs +9 -5
  97. package/dist/notification.interface-BNCvnVCW.d.ts +34 -0
  98. package/dist/notification.interface-P_pNfzZQ.d.mts +34 -0
  99. package/dist/{s3.service-Bqr-fQmi.d.ts → s3.service-CbUa6a-8.d.ts} +3 -2
  100. package/dist/{s3.service-Buiy1ncj.d.mts → s3.service-DAp8FbuG.d.mts} +3 -2
  101. package/dist/server/index.d.mts +5 -4
  102. package/dist/server/index.d.ts +5 -4
  103. package/dist/server/index.js +39 -279
  104. package/dist/server/index.js.map +1 -1
  105. package/dist/server/index.mjs +20 -260
  106. package/dist/server/index.mjs.map +1 -1
  107. package/dist/{types-bsm8Fp55.d.ts → types-Cn153-mH.d.ts} +1 -1
  108. package/dist/{types-Bq_UA8Lg.d.mts → types-D0uLYV2P.d.mts} +1 -1
  109. package/dist/{usePageUrlGenerator-J_ECv-oQ.d.ts → usePageUrlGenerator-B-3FRKBD.d.ts} +1 -1
  110. package/dist/{usePageUrlGenerator-uKBjAKqC.d.mts → usePageUrlGenerator-CCJ_cdQy.d.mts} +1 -1
  111. package/dist/{useSocket-BcH7l7B8.d.mts → useSocket-B9gTbuCj.d.mts} +1 -1
  112. package/dist/{useSocket-DCZd_eJk.d.ts → useSocket-BHVIyigI.d.ts} +1 -1
  113. package/dist/{notification.interface-MnRt1Tjm.d.mts → user.interface-CSaGWFbg.d.mts} +1 -31
  114. package/dist/{notification.interface-CjzV_KII.d.ts → user.interface-CrmwJLYW.d.ts} +1 -31
  115. package/package.json +11 -1
  116. package/src/client/index.ts +2 -0
  117. package/src/components/containers/PageContainer.tsx +1 -1
  118. package/src/components/containers/RoundPageContainer.tsx +60 -16
  119. package/src/components/containers/RoundPageContainerTitle.tsx +75 -35
  120. package/src/components/editors/BlockNoteEditor.tsx +13 -1
  121. package/src/components/forms/EntityMultiSelector.tsx +4 -4
  122. package/src/components/forms/EntitySelector.tsx +4 -4
  123. package/src/components/forms/FormBlockNote.tsx +21 -1
  124. package/src/components/forms/FormCheckbox.tsx +6 -2
  125. package/src/components/forms/__tests__/useEditorDialog.test.ts +47 -0
  126. package/src/components/forms/useEditorDialog.ts +22 -0
  127. package/src/components/navigations/Breadcrumb.tsx +45 -16
  128. package/src/components/navigations/Header.tsx +16 -3
  129. package/src/components/navigations/MobileNavigationBar.tsx +12 -0
  130. package/src/components/navigations/__tests__/MobileNavigationBar.spec.tsx +17 -0
  131. package/src/components/tables/ContentListTable.tsx +52 -32
  132. package/src/core/index.ts +4 -0
  133. package/src/core/registry/ModuleRegistry.ts +7 -0
  134. package/src/features/__tests__/i18n-keys.contract.spec.ts +1 -0
  135. package/src/features/analytics/__tests__/server-entry.spec.tsx +64 -0
  136. package/src/features/analytics/analytics.modules.ts +30 -0
  137. package/src/features/analytics/components/AnalyticsAdminContainer.tsx +97 -0
  138. package/src/features/analytics/components/AnalyticsAdminFilterBar.tsx +120 -0
  139. package/src/features/analytics/components/AnalyticsAdminPageContainer.tsx +25 -0
  140. package/src/features/analytics/components/AnalyticsAdminTiles.tsx +191 -0
  141. package/src/features/analytics/components/AnalyticsBreakdownTable.tsx +105 -0
  142. package/src/features/analytics/components/AnalyticsJourneyContainer.tsx +246 -0
  143. package/src/features/analytics/components/AnalyticsJourneyPageContainer.tsx +14 -0
  144. package/src/features/analytics/components/AnalyticsSessionsTable.tsx +107 -0
  145. package/src/features/analytics/components/AnalyticsTimelineChart.tsx +175 -0
  146. package/src/features/analytics/components/AnalyticsTracker.tsx +112 -0
  147. package/src/features/analytics/components/__tests__/AnalyticsAdminTiles.spec.tsx +39 -0
  148. package/src/features/analytics/components/__tests__/AnalyticsBreakdownTable.spec.tsx +39 -0
  149. package/src/features/analytics/components/__tests__/AnalyticsJourneyContainer.spec.tsx +124 -0
  150. package/src/features/analytics/components/__tests__/AnalyticsPageContainers.spec.tsx +117 -0
  151. package/src/features/analytics/components/__tests__/AnalyticsSessionsTable.spec.tsx +121 -0
  152. package/src/features/analytics/components/__tests__/AnalyticsTimelineChart.spec.tsx +35 -0
  153. package/src/features/analytics/components/__tests__/AnalyticsTracker.spec.tsx +248 -0
  154. package/src/features/analytics/components/server/AnalyticsAdminPage.tsx +28 -0
  155. package/src/features/analytics/components/server/AnalyticsJourneyPage.tsx +25 -0
  156. package/src/features/analytics/contexts/AnalyticsAdminContext.tsx +273 -0
  157. package/src/features/analytics/contexts/__tests__/AnalyticsAdminContext.spec.tsx +140 -0
  158. package/src/features/analytics/data/AnalyticsAdminService.ts +91 -0
  159. package/src/features/analytics/data/AnalyticsService.ts +23 -0
  160. package/src/features/analytics/data/__tests__/AnalyticsAdminService.spec.ts +117 -0
  161. package/src/features/analytics/data/__tests__/AnalyticsService.spec.ts +56 -0
  162. package/src/features/analytics/data/__tests__/models.spec.ts +300 -0
  163. package/src/features/analytics/data/analytics-breakdown.interface.ts +9 -0
  164. package/src/features/analytics/data/analytics-breakdown.ts +65 -0
  165. package/src/features/analytics/data/analytics-event.interface.ts +7 -0
  166. package/src/features/analytics/data/analytics-event.ts +63 -0
  167. package/src/features/analytics/data/analytics-page-view.interface.ts +9 -0
  168. package/src/features/analytics/data/analytics-page-view.ts +57 -0
  169. package/src/features/analytics/data/analytics-session.interface.ts +23 -0
  170. package/src/features/analytics/data/analytics-session.ts +144 -0
  171. package/src/features/analytics/data/analytics-summary.interface.ts +11 -0
  172. package/src/features/analytics/data/analytics-summary.ts +79 -0
  173. package/src/features/analytics/data/analytics-timeline.interface.ts +10 -0
  174. package/src/features/analytics/data/analytics-timeline.ts +67 -0
  175. package/src/features/analytics/data/analytics.types.ts +97 -0
  176. package/src/features/analytics/data/index.ts +15 -0
  177. package/src/features/analytics/i18n-keys.ts +76 -0
  178. package/src/features/analytics/index.ts +31 -0
  179. package/src/features/analytics/lib/__tests__/utm.spec.ts +20 -0
  180. package/src/features/analytics/lib/__tests__/visitorCookie.spec.ts +63 -0
  181. package/src/features/analytics/lib/utm.ts +29 -0
  182. package/src/features/analytics/lib/visitorCookie.ts +32 -0
  183. package/src/features/analytics/server-entry.ts +8 -0
  184. package/src/features/assistant/components/parts/AssistantBlockNoteComposer.tsx +1 -1
  185. package/src/features/assistant/components/parts/AssistantComposer.tsx +1 -1
  186. package/src/features/assistant-message/components/MessageItem.tsx +2 -2
  187. package/src/features/assistant-message/components/parts/tabs/CitationsTab.tsx +23 -5
  188. package/src/features/auth/components/containers/AuthContainer.tsx +8 -1
  189. package/src/features/company/components/details/CompanyContent.tsx +3 -1
  190. package/src/features/company/components/details/CompanyDetails.tsx +9 -1
  191. package/src/features/company/components/details/TokenStatusIndicator.tsx +42 -13
  192. package/src/features/help/components/HelpHeader.tsx +7 -5
  193. package/src/features/tokenusage/components/TokenUsageAdminFilterBar.tsx +9 -6
  194. package/src/features/tokenusage/lib/__tests__/formatters.spec.ts +21 -0
  195. package/src/features/tokenusage/lib/formatters.ts +5 -1
  196. package/src/features/waitlist/components/lists/WaitlistList.tsx +68 -121
  197. package/src/features/waitlist/data/WaitlistService.ts +2 -2
  198. package/src/features/waitlist/data/index.ts +1 -0
  199. package/src/features/waitlist/data/waitlist.fields.ts +8 -0
  200. package/src/features/waitlist/hooks/useWaitlistTableStructure.tsx +69 -30
  201. package/src/shadcnui/ui/button.tsx +1 -1
  202. package/src/shadcnui/ui/checkbox.tsx +1 -1
  203. package/src/shadcnui/ui/command.tsx +4 -4
  204. package/src/shadcnui/ui/dialog.tsx +2 -0
  205. package/src/shadcnui/ui/input.tsx +1 -1
  206. package/src/shadcnui/ui/popover.tsx +9 -0
  207. package/src/shadcnui/ui/select.tsx +1 -1
  208. package/src/shadcnui/ui/sheet.tsx +8 -1
  209. package/src/shadcnui/ui/table.tsx +2 -2
  210. package/src/shadcnui/ui/textarea.tsx +1 -1
  211. package/dist/BlockNoteEditor-35D4R7HJ.js.map +0 -1
  212. package/dist/JsonApiRequest-G625QAH5.js +0 -26
  213. package/dist/chunk-2XCUAHQT.js.map +0 -1
  214. package/dist/chunk-4O7R2Z2O.mjs.map +0 -1
  215. package/dist/chunk-IBVKPLHT.mjs.map +0 -1
  216. package/dist/chunk-U7QVISQS.js.map +0 -1
  217. /package/dist/{BlockNoteViewer-KDKGUIG5.mjs.map → BlockNoteViewer-KRTAFTUX.mjs.map} +0 -0
  218. /package/dist/{JsonApiRequest-RRMNV7YE.mjs.map → JsonApiRequest-UHOWNI3O.mjs.map} +0 -0
  219. /package/dist/{chunk-T76WLDE5.mjs.map → chunk-37DMFVLN.mjs.map} +0 -0
  220. /package/dist/{chunk-ZJI5ZZW5.mjs.map → chunk-Q6J2ICKO.mjs.map} +0 -0
@@ -0,0 +1,300 @@
1
+ import { beforeAll, describe, expect, it } from "vitest";
2
+ import { ApiRequestDataTypeInterface } from "../../../../core/interfaces/ApiRequestDataTypeInterface";
3
+ import { DataClassRegistry } from "../../../../core/registry/DataClassRegistry";
4
+ import { ModuleRegistry } from "../../../../core/registry/ModuleRegistry";
5
+ import { ModuleFactory, ModuleWithPermissions } from "../../../../permissions/types";
6
+ import { AbstractApiData } from "../../../../core/abstracts/AbstractApiData";
7
+ import { JsonApiHydratedDataInterface } from "../../../../core/interfaces/JsonApiHydratedDataInterface";
8
+ import { analyticsModules } from "../../analytics.modules";
9
+ import { AnalyticsBreakdown } from "../analytics-breakdown";
10
+ import { AnalyticsEvent } from "../analytics-event";
11
+ import { AnalyticsPageView } from "../analytics-page-view";
12
+ import { AnalyticsSession } from "../analytics-session";
13
+ import { AnalyticsSummary } from "../analytics-summary";
14
+ import { AnalyticsTimeline } from "../analytics-timeline";
15
+
16
+ // createJsonApi reads Modules.X.name (resolved lazily via ModuleRegistry), so
17
+ // the modules must be registered before a model serialises — mirrors the
18
+ // tokenusage-admin-models.spec setup. ModuleRegistry is shared across test
19
+ // files in a worker, hence the register-if-absent guard.
20
+ const moduleFactory: ModuleFactory = (params) =>
21
+ ({
22
+ pageUrl: params.pageUrl,
23
+ name: params.name,
24
+ model: params.model,
25
+ }) as ModuleWithPermissions;
26
+
27
+ const registerIfAbsent = (key: string, module: ApiRequestDataTypeInterface) => {
28
+ try {
29
+ ModuleRegistry.get(key as any);
30
+ } catch {
31
+ ModuleRegistry.register(key, module);
32
+ }
33
+ };
34
+
35
+ // The real User model rehydrates roles and company, which needs those modules
36
+ // registered too. The session only cares that the included user is read
37
+ // through Modules.User, so a minimal stand-in keeps the fixture small.
38
+ class StubUser extends AbstractApiData {
39
+ name = "";
40
+ rehydrate(data: JsonApiHydratedDataInterface): this {
41
+ super.rehydrate(data);
42
+ this.name = data.jsonApi.attributes.name ?? "";
43
+ return this;
44
+ }
45
+ }
46
+
47
+ const userModule = { name: "users", model: StubUser } as ApiRequestDataTypeInterface;
48
+
49
+ beforeAll(() => {
50
+ for (const [key, module] of Object.entries(analyticsModules(moduleFactory))) registerIfAbsent(key, module);
51
+ registerIfAbsent("User", userModule);
52
+ DataClassRegistry.registerObjectClass(userModule, StubUser);
53
+ });
54
+
55
+ const hydrate = (attributes: Record<string, unknown>, extra: Record<string, unknown> = {}) =>
56
+ ({ jsonApi: { id: "x", type: "t", attributes, ...extra }, included: [] }) as any;
57
+
58
+ describe("analytics models — rehydrate", () => {
59
+ it("rehydrates every summary attribute", () => {
60
+ const m = new AnalyticsSummary().rehydrate(
61
+ hydrate({
62
+ section: "all",
63
+ window: "current",
64
+ visitors: 120,
65
+ sessions: 150,
66
+ pageViews: 480,
67
+ pagesPerSession: 3.2,
68
+ consentShare: 0.4,
69
+ }),
70
+ );
71
+ expect(m.section).toBe("all");
72
+ expect(m.window).toBe("current");
73
+ expect(m.visitors).toBe(120);
74
+ expect(m.sessions).toBe(150);
75
+ expect(m.pageViews).toBe(480);
76
+ expect(m.pagesPerSession).toBe(3.2);
77
+ expect(m.consentShare).toBe(0.4);
78
+ });
79
+
80
+ it("parses the timeline bucket into a Date, not the wire string", () => {
81
+ const m = new AnalyticsTimeline().rehydrate(
82
+ hydrate({ bucket: "2026-09-01", section: "public", visitors: 1, sessions: 2, pageViews: 3 }),
83
+ );
84
+ expect(m.bucket).toBeInstanceOf(Date);
85
+ expect(m.bucket.toISOString().slice(0, 10)).toBe("2026-09-01");
86
+ expect(m.section).toBe("public");
87
+ expect(m.visitors).toBe(1);
88
+ expect(m.sessions).toBe(2);
89
+ expect(m.pageViews).toBe(3);
90
+ });
91
+
92
+ it("rehydrates every breakdown attribute", () => {
93
+ const m = new AnalyticsBreakdown().rehydrate(
94
+ hydrate({ dimension: "source", key: "linkedin", visitors: 4, sessions: 5, pageViews: 6 }),
95
+ );
96
+ expect(m.dimension).toBe("source");
97
+ expect(m.key).toBe("linkedin");
98
+ expect(m.visitors).toBe(4);
99
+ expect(m.sessions).toBe(5);
100
+ expect(m.pageViews).toBe(6);
101
+ });
102
+
103
+ it("parses session datetimes into Dates and leaves absent optionals undefined", () => {
104
+ const m = new AnalyticsSession().rehydrate(
105
+ hydrate({
106
+ visitorId: "v1",
107
+ consented: true,
108
+ startedAt: "2026-09-01T10:00:00.000Z",
109
+ lastSeenAt: "2026-09-01T10:12:00.000Z",
110
+ pageViews: 4,
111
+ section: "app",
112
+ landingRoute: "/rolls/:id",
113
+ utmSource: "linkedin",
114
+ deviceType: "desktop",
115
+ }),
116
+ );
117
+ expect(m.visitorId).toBe("v1");
118
+ expect(m.consented).toBe(true);
119
+ expect(m.startedAt).toBeInstanceOf(Date);
120
+ expect(m.startedAt.toISOString()).toBe("2026-09-01T10:00:00.000Z");
121
+ expect(m.lastSeenAt).toBeInstanceOf(Date);
122
+ expect(m.lastSeenAt.toISOString()).toBe("2026-09-01T10:12:00.000Z");
123
+ expect(m.pageViews).toBe(4);
124
+ expect(m.section).toBe("app");
125
+ expect(m.landingRoute).toBe("/rolls/:id");
126
+ expect(m.utmSource).toBe("linkedin");
127
+ expect(m.referrerHost).toBeUndefined();
128
+ expect(m.utmMedium).toBeUndefined();
129
+ expect(m.deviceType).toBe("desktop");
130
+ expect(m.user).toBeUndefined();
131
+ });
132
+
133
+ it("reads the included user of a session", () => {
134
+ const m = new AnalyticsSession().rehydrate({
135
+ jsonApi: {
136
+ id: "s1",
137
+ type: "analytics/administration/sessions",
138
+ attributes: {
139
+ visitorId: "v1",
140
+ consented: true,
141
+ startedAt: "2026-09-01T10:00:00.000Z",
142
+ lastSeenAt: "2026-09-01T10:00:00.000Z",
143
+ pageViews: 1,
144
+ section: "app",
145
+ landingRoute: "/",
146
+ deviceType: "mobile",
147
+ },
148
+ relationships: { user: { data: { type: "users", id: "u1" } } },
149
+ },
150
+ included: [
151
+ {
152
+ type: "users",
153
+ id: "u1",
154
+ attributes: { name: "Marco Rossi", email: "m@example.com" },
155
+ },
156
+ ],
157
+ } as any);
158
+
159
+ expect(m.user).toBeDefined();
160
+ expect(m.user?.id).toBe("u1");
161
+ expect(m.user?.name).toBe("Marco Rossi");
162
+ });
163
+
164
+ it("parses the page view createdAt from meta into a Date (the serialiser's placement)", () => {
165
+ const m = new AnalyticsPageView().rehydrate(
166
+ hydrate(
167
+ { path: "/pricing", route: "/pricing", section: "public" },
168
+ { meta: { createdAt: "2026-09-01T10:05:00.000Z" } },
169
+ ),
170
+ );
171
+ expect(m.createdAt).toBeInstanceOf(Date);
172
+ expect(m.createdAt.toISOString()).toBe("2026-09-01T10:05:00.000Z");
173
+ });
174
+
175
+ it("parses the page view createdAt into a Date when sent as an attribute", () => {
176
+ const m = new AnalyticsPageView().rehydrate(
177
+ hydrate({ createdAt: "2026-09-01T10:05:00.000Z", path: "/rolls/abc", route: "/rolls/:id", section: "app" }),
178
+ );
179
+ expect(m.createdAt).toBeInstanceOf(Date);
180
+ expect(m.createdAt.toISOString()).toBe("2026-09-01T10:05:00.000Z");
181
+ expect(m.path).toBe("/rolls/abc");
182
+ expect(m.route).toBe("/rolls/:id");
183
+ expect(m.section).toBe("app");
184
+ });
185
+
186
+ it("reads path and section back on the event", () => {
187
+ const m = new AnalyticsEvent().rehydrate(hydrate({ path: "/pricing", section: "public" }));
188
+ expect(m.path).toBe("/pricing");
189
+ expect(m.section).toBe("public");
190
+ });
191
+ });
192
+
193
+ describe("analytics models — createJsonApi", () => {
194
+ it("serialises an event with the API resource type and only the attributes given", () => {
195
+ const doc = new AnalyticsEvent().createJsonApi({
196
+ id: "e1",
197
+ path: "/pricing",
198
+ section: "public",
199
+ utmSource: "linkedin",
200
+ screenWidth: 1440,
201
+ });
202
+
203
+ // Must equal analyticsEventMeta.type: the API DTO rejects anything else.
204
+ expect(doc.data.type).toBe("analytics-events");
205
+ expect(doc.data.id).toBe("e1");
206
+ expect(doc.data.attributes).toEqual({
207
+ path: "/pricing",
208
+ section: "public",
209
+ utmSource: "linkedin",
210
+ screenWidth: 1440,
211
+ });
212
+ expect(Object.keys(doc.data.relationships ?? {})).toHaveLength(0);
213
+ });
214
+
215
+ it("serialises a summary back to a JSON:API resource object", () => {
216
+ const doc = new AnalyticsSummary().createJsonApi({
217
+ id: "all|current",
218
+ section: "all",
219
+ window: "current",
220
+ visitors: 1,
221
+ sessions: 2,
222
+ pageViews: 3,
223
+ pagesPerSession: 1.5,
224
+ consentShare: 0.5,
225
+ });
226
+ expect(doc.data.type).toBe("analytics/administration/summary");
227
+ expect(doc.data.id).toBe("all|current");
228
+ expect(doc.data.attributes).toMatchObject({ section: "all", window: "current", visitors: 1, consentShare: 0.5 });
229
+ });
230
+
231
+ it("emits the timeline bucket as YYYY-MM-DD via formatLocalDate, never a raw Date", () => {
232
+ const doc = new AnalyticsTimeline().createJsonApi({
233
+ id: "2026-09-01|public",
234
+ bucket: new Date(2026, 8, 1),
235
+ section: "public",
236
+ visitors: 1,
237
+ sessions: 1,
238
+ pageViews: 1,
239
+ });
240
+ expect(doc.data.attributes.bucket).toBe("2026-09-01");
241
+ expect(JSON.parse(JSON.stringify(doc)).data.attributes.bucket).toBe("2026-09-01");
242
+ });
243
+
244
+ it("serialises a breakdown row", () => {
245
+ const doc = new AnalyticsBreakdown().createJsonApi({
246
+ id: "source|linkedin",
247
+ dimension: "source",
248
+ key: "linkedin",
249
+ visitors: 1,
250
+ sessions: 2,
251
+ pageViews: 3,
252
+ });
253
+ expect(doc.data.type).toBe("analytics/administration/breakdown");
254
+ expect(doc.data.attributes).toEqual({
255
+ dimension: "source",
256
+ key: "linkedin",
257
+ visitors: 1,
258
+ sessions: 2,
259
+ pageViews: 3,
260
+ });
261
+ });
262
+
263
+ it("emits session datetimes as ISO strings, omits absent optionals, and links the user", () => {
264
+ const doc = new AnalyticsSession().createJsonApi({
265
+ id: "s1",
266
+ visitorId: "v1",
267
+ consented: false,
268
+ startedAt: new Date("2026-09-01T10:00:00.000Z"),
269
+ lastSeenAt: new Date("2026-09-01T10:12:00.000Z"),
270
+ pageViews: 2,
271
+ section: "app",
272
+ landingRoute: "/",
273
+ deviceType: "tablet",
274
+ userId: "u1",
275
+ });
276
+ expect(doc.data.type).toBe("analytics/administration/sessions");
277
+ expect(doc.data.attributes.startedAt).toBe("2026-09-01T10:00:00.000Z");
278
+ expect(doc.data.attributes.lastSeenAt).toBe("2026-09-01T10:12:00.000Z");
279
+ expect("referrerHost" in doc.data.attributes).toBe(false);
280
+ expect("utmSource" in doc.data.attributes).toBe(false);
281
+ expect(doc.data.relationships.user).toEqual({ data: { type: "users", id: "u1" } });
282
+ });
283
+
284
+ it("emits the page view createdAt as an ISO string", () => {
285
+ const doc = new AnalyticsPageView().createJsonApi({
286
+ id: "p1",
287
+ createdAt: new Date("2026-09-01T10:05:00.000Z"),
288
+ path: "/rolls/abc",
289
+ route: "/rolls/:id",
290
+ section: "app",
291
+ });
292
+ expect(doc.data.type).toBe("pageviews");
293
+ expect(doc.data.attributes).toEqual({
294
+ createdAt: "2026-09-01T10:05:00.000Z",
295
+ path: "/rolls/abc",
296
+ route: "/rolls/:id",
297
+ section: "app",
298
+ });
299
+ });
300
+ });
@@ -0,0 +1,9 @@
1
+ import { ApiDataInterface } from "../../../core";
2
+
3
+ export interface AnalyticsBreakdownInterface extends ApiDataInterface {
4
+ get dimension(): string;
5
+ get key(): string;
6
+ get visitors(): number;
7
+ get sessions(): number;
8
+ get pageViews(): number;
9
+ }
@@ -0,0 +1,65 @@
1
+ import { AbstractApiData, JsonApiHydratedDataInterface, Modules } from "../../../core";
2
+ import { AnalyticsBreakdownInterface } from "./analytics-breakdown.interface";
3
+ import { AnalyticsBreakdownInput } from "./analytics.types";
4
+
5
+ /** One row of a breakdown table: a key (source, route, …) and its counts. */
6
+ export class AnalyticsBreakdown extends AbstractApiData implements AnalyticsBreakdownInterface {
7
+ private _dimension: string = "";
8
+ private _key: string = "";
9
+ private _visitors: number = 0;
10
+ private _sessions: number = 0;
11
+ private _pageViews: number = 0;
12
+
13
+ get dimension(): string {
14
+ return this._dimension;
15
+ }
16
+
17
+ get key(): string {
18
+ return this._key;
19
+ }
20
+
21
+ get visitors(): number {
22
+ return this._visitors;
23
+ }
24
+
25
+ get sessions(): number {
26
+ return this._sessions;
27
+ }
28
+
29
+ get pageViews(): number {
30
+ return this._pageViews;
31
+ }
32
+
33
+ rehydrate(data: JsonApiHydratedDataInterface): this {
34
+ super.rehydrate(data);
35
+
36
+ const attrs = data.jsonApi.attributes;
37
+ this._dimension = attrs.dimension ?? "";
38
+ this._key = attrs.key ?? "";
39
+ this._visitors = attrs.visitors ?? 0;
40
+ this._sessions = attrs.sessions ?? 0;
41
+ this._pageViews = attrs.pageViews ?? 0;
42
+
43
+ return this;
44
+ }
45
+
46
+ createJsonApi(data: AnalyticsBreakdownInput) {
47
+ const response: any = {
48
+ data: {
49
+ type: Modules.AnalyticsBreakdown.name,
50
+ id: data.id,
51
+ attributes: {},
52
+ relationships: {},
53
+ },
54
+ included: [],
55
+ };
56
+
57
+ if (data.dimension !== undefined) response.data.attributes.dimension = data.dimension;
58
+ if (data.key !== undefined) response.data.attributes.key = data.key;
59
+ if (data.visitors !== undefined) response.data.attributes.visitors = data.visitors;
60
+ if (data.sessions !== undefined) response.data.attributes.sessions = data.sessions;
61
+ if (data.pageViews !== undefined) response.data.attributes.pageViews = data.pageViews;
62
+
63
+ return response;
64
+ }
65
+ }
@@ -0,0 +1,7 @@
1
+ import { ApiDataInterface } from "../../../core";
2
+
3
+ /** Write-only resource: the tracker posts it, rehydrate reads the two attributes back. */
4
+ export interface AnalyticsEventInterface extends ApiDataInterface {
5
+ get path(): string;
6
+ get section(): string;
7
+ }
@@ -0,0 +1,63 @@
1
+ import { AbstractApiData, JsonApiHydratedDataInterface } from "../../../core";
2
+ import { AnalyticsEventInterface } from "./analytics-event.interface";
3
+ import { AnalyticsEventInput } from "./analytics.types";
4
+
5
+ /**
6
+ * JSON:API resource type of a posted event. It must match `analyticsEventMeta.type`
7
+ * in the API, whose DTO rejects any other value. It is not `Modules.AnalyticsEvent.name`:
8
+ * that is the endpoint path ("analytics/events"), the same split the auth models
9
+ * make (e.g. backup-code-verify.ts posts type "backup-codes").
10
+ */
11
+ const ANALYTICS_EVENT_TYPE = "analytics-events";
12
+
13
+ /**
14
+ * One page view as the tracker posts it. The API answers with the stored event,
15
+ * so rehydrate reads back the two attributes every event carries.
16
+ */
17
+ export class AnalyticsEvent extends AbstractApiData implements AnalyticsEventInterface {
18
+ private _path: string = "";
19
+ private _section: string = "";
20
+
21
+ get path(): string {
22
+ return this._path;
23
+ }
24
+
25
+ get section(): string {
26
+ return this._section;
27
+ }
28
+
29
+ rehydrate(data: JsonApiHydratedDataInterface): this {
30
+ super.rehydrate(data);
31
+
32
+ const attrs = data.jsonApi.attributes;
33
+ this._path = attrs.path ?? "";
34
+ this._section = attrs.section ?? "";
35
+
36
+ return this;
37
+ }
38
+
39
+ createJsonApi(data: AnalyticsEventInput) {
40
+ const response: any = {
41
+ data: {
42
+ type: ANALYTICS_EVENT_TYPE,
43
+ id: data.id,
44
+ attributes: {},
45
+ relationships: {},
46
+ },
47
+ included: [],
48
+ };
49
+
50
+ response.data.attributes.path = data.path;
51
+ response.data.attributes.section = data.section;
52
+ if (data.referrer !== undefined) response.data.attributes.referrer = data.referrer;
53
+ if (data.utmSource !== undefined) response.data.attributes.utmSource = data.utmSource;
54
+ if (data.utmMedium !== undefined) response.data.attributes.utmMedium = data.utmMedium;
55
+ if (data.utmCampaign !== undefined) response.data.attributes.utmCampaign = data.utmCampaign;
56
+ if (data.utmTerm !== undefined) response.data.attributes.utmTerm = data.utmTerm;
57
+ if (data.utmContent !== undefined) response.data.attributes.utmContent = data.utmContent;
58
+ if (data.visitorId !== undefined) response.data.attributes.visitorId = data.visitorId;
59
+ if (data.screenWidth !== undefined) response.data.attributes.screenWidth = data.screenWidth;
60
+
61
+ return response;
62
+ }
63
+ }
@@ -0,0 +1,9 @@
1
+ import { ApiDataInterface } from "../../../core";
2
+
3
+ export interface AnalyticsPageViewInterface extends ApiDataInterface {
4
+ /** Backend declares this `type: "datetime"`, so it is a Date in memory. */
5
+ get createdAt(): Date;
6
+ get path(): string;
7
+ get route(): string;
8
+ get section(): string;
9
+ }
@@ -0,0 +1,57 @@
1
+ import { AbstractApiData, JsonApiHydratedDataInterface, Modules } from "../../../core";
2
+ import { AnalyticsPageViewInterface } from "./analytics-page-view.interface";
3
+ import { AnalyticsPageViewInput } from "./analytics.types";
4
+
5
+ /** One step of a session journey. */
6
+ export class AnalyticsPageView extends AbstractApiData implements AnalyticsPageViewInterface {
7
+ private _path: string = "";
8
+ private _route: string = "";
9
+ private _section: string = "";
10
+
11
+ get path(): string {
12
+ return this._path;
13
+ }
14
+
15
+ get route(): string {
16
+ return this._route;
17
+ }
18
+
19
+ get section(): string {
20
+ return this._section;
21
+ }
22
+
23
+ rehydrate(data: JsonApiHydratedDataInterface): this {
24
+ super.rehydrate(data);
25
+
26
+ const attrs = data.jsonApi.attributes;
27
+ // `createdAt` is inherited: the serialiser sends it in meta and
28
+ // super.rehydrate() parses it into a Date. Honour it as an attribute too,
29
+ // should the descriptor ever list it among the fields.
30
+ if (attrs.createdAt) this._createdAt = new Date(attrs.createdAt);
31
+ this._path = attrs.path ?? "";
32
+ this._route = attrs.route ?? "";
33
+ this._section = attrs.section ?? "";
34
+
35
+ return this;
36
+ }
37
+
38
+ createJsonApi(data: AnalyticsPageViewInput) {
39
+ const response: any = {
40
+ data: {
41
+ type: Modules.AnalyticsPageView.name,
42
+ id: data.id,
43
+ attributes: {},
44
+ relationships: {},
45
+ },
46
+ included: [],
47
+ };
48
+
49
+ // type: "datetime" on the backend descriptor: an ISO 8601 instant.
50
+ if (data.createdAt !== undefined) response.data.attributes.createdAt = data.createdAt.toISOString();
51
+ if (data.path !== undefined) response.data.attributes.path = data.path;
52
+ if (data.route !== undefined) response.data.attributes.route = data.route;
53
+ if (data.section !== undefined) response.data.attributes.section = data.section;
54
+
55
+ return response;
56
+ }
57
+ }
@@ -0,0 +1,23 @@
1
+ import { ApiDataInterface } from "../../../core";
2
+ import { UserInterface } from "../../user/data/user.interface";
3
+
4
+ export interface AnalyticsSessionInterface extends ApiDataInterface {
5
+ get visitorId(): string;
6
+ get consented(): boolean;
7
+ /** Backend declares this `type: "datetime"`, so it is a Date in memory. */
8
+ get startedAt(): Date;
9
+ /** Backend declares this `type: "datetime"`, so it is a Date in memory. */
10
+ get lastSeenAt(): Date;
11
+ get pageViews(): number;
12
+ get section(): string;
13
+ get landingRoute(): string;
14
+ get referrerHost(): string | undefined;
15
+ get utmSource(): string | undefined;
16
+ get utmMedium(): string | undefined;
17
+ get utmCampaign(): string | undefined;
18
+ get utmTerm(): string | undefined;
19
+ get utmContent(): string | undefined;
20
+ get deviceType(): string;
21
+ /** Present only when the session belongs to a signed-in user. */
22
+ get user(): UserInterface | undefined;
23
+ }
@@ -0,0 +1,144 @@
1
+ import { AbstractApiData, JsonApiHydratedDataInterface, Modules } from "../../../core";
2
+ import { UserInterface } from "../../user/data/user.interface";
3
+ import { AnalyticsSessionInterface } from "./analytics-session.interface";
4
+ import { AnalyticsSessionInput } from "./analytics.types";
5
+
6
+ /** One visit: a run of page views from one visitor with no 30-minute gap. */
7
+ export class AnalyticsSession extends AbstractApiData implements AnalyticsSessionInterface {
8
+ private _visitorId: string = "";
9
+ private _consented: boolean = false;
10
+ private _startedAt: Date = new Date(0);
11
+ private _lastSeenAt: Date = new Date(0);
12
+ private _pageViews: number = 0;
13
+ private _section: string = "";
14
+ private _landingRoute: string = "";
15
+ private _referrerHost?: string;
16
+ private _utmSource?: string;
17
+ private _utmMedium?: string;
18
+ private _utmCampaign?: string;
19
+ private _utmTerm?: string;
20
+ private _utmContent?: string;
21
+ private _deviceType: string = "";
22
+ private _user?: UserInterface;
23
+
24
+ get visitorId(): string {
25
+ return this._visitorId;
26
+ }
27
+
28
+ get consented(): boolean {
29
+ return this._consented;
30
+ }
31
+
32
+ get startedAt(): Date {
33
+ return this._startedAt;
34
+ }
35
+
36
+ get lastSeenAt(): Date {
37
+ return this._lastSeenAt;
38
+ }
39
+
40
+ get pageViews(): number {
41
+ return this._pageViews;
42
+ }
43
+
44
+ get section(): string {
45
+ return this._section;
46
+ }
47
+
48
+ get landingRoute(): string {
49
+ return this._landingRoute;
50
+ }
51
+
52
+ get referrerHost(): string | undefined {
53
+ return this._referrerHost;
54
+ }
55
+
56
+ get utmSource(): string | undefined {
57
+ return this._utmSource;
58
+ }
59
+
60
+ get utmMedium(): string | undefined {
61
+ return this._utmMedium;
62
+ }
63
+
64
+ get utmCampaign(): string | undefined {
65
+ return this._utmCampaign;
66
+ }
67
+
68
+ get utmTerm(): string | undefined {
69
+ return this._utmTerm;
70
+ }
71
+
72
+ get utmContent(): string | undefined {
73
+ return this._utmContent;
74
+ }
75
+
76
+ get deviceType(): string {
77
+ return this._deviceType;
78
+ }
79
+
80
+ get user(): UserInterface | undefined {
81
+ return this._user;
82
+ }
83
+
84
+ rehydrate(data: JsonApiHydratedDataInterface): this {
85
+ super.rehydrate(data);
86
+
87
+ const attrs = data.jsonApi.attributes;
88
+ this._visitorId = attrs.visitorId ?? "";
89
+ this._consented = attrs.consented ?? false;
90
+ // Wire format is an ISO 8601 instant; the interface promises a Date.
91
+ this._startedAt = attrs.startedAt ? new Date(attrs.startedAt) : new Date(0);
92
+ this._lastSeenAt = attrs.lastSeenAt ? new Date(attrs.lastSeenAt) : new Date(0);
93
+ this._pageViews = attrs.pageViews ?? 0;
94
+ this._section = attrs.section ?? "";
95
+ this._landingRoute = attrs.landingRoute ?? "";
96
+ this._referrerHost = attrs.referrerHost ?? undefined;
97
+ this._utmSource = attrs.utmSource ?? undefined;
98
+ this._utmMedium = attrs.utmMedium ?? undefined;
99
+ this._utmCampaign = attrs.utmCampaign ?? undefined;
100
+ this._utmTerm = attrs.utmTerm ?? undefined;
101
+ this._utmContent = attrs.utmContent ?? undefined;
102
+ this._deviceType = attrs.deviceType ?? "";
103
+
104
+ this._user = this._readIncluded(data, "user", Modules.User) as UserInterface | undefined;
105
+
106
+ return this;
107
+ }
108
+
109
+ createJsonApi(data: AnalyticsSessionInput) {
110
+ const response: any = {
111
+ data: {
112
+ type: Modules.AnalyticsSession.name,
113
+ id: data.id,
114
+ attributes: {},
115
+ relationships: {},
116
+ },
117
+ included: [],
118
+ };
119
+
120
+ if (data.visitorId !== undefined) response.data.attributes.visitorId = data.visitorId;
121
+ if (data.consented !== undefined) response.data.attributes.consented = data.consented;
122
+ // Both are type: "datetime" on the backend descriptor: ISO 8601 instants.
123
+ if (data.startedAt !== undefined) response.data.attributes.startedAt = data.startedAt.toISOString();
124
+ if (data.lastSeenAt !== undefined) response.data.attributes.lastSeenAt = data.lastSeenAt.toISOString();
125
+ if (data.pageViews !== undefined) response.data.attributes.pageViews = data.pageViews;
126
+ if (data.section !== undefined) response.data.attributes.section = data.section;
127
+ if (data.landingRoute !== undefined) response.data.attributes.landingRoute = data.landingRoute;
128
+ if (data.referrerHost !== undefined) response.data.attributes.referrerHost = data.referrerHost;
129
+ if (data.utmSource !== undefined) response.data.attributes.utmSource = data.utmSource;
130
+ if (data.utmMedium !== undefined) response.data.attributes.utmMedium = data.utmMedium;
131
+ if (data.utmCampaign !== undefined) response.data.attributes.utmCampaign = data.utmCampaign;
132
+ if (data.utmTerm !== undefined) response.data.attributes.utmTerm = data.utmTerm;
133
+ if (data.utmContent !== undefined) response.data.attributes.utmContent = data.utmContent;
134
+ if (data.deviceType !== undefined) response.data.attributes.deviceType = data.deviceType;
135
+
136
+ if (data.userId) {
137
+ response.data.relationships.user = {
138
+ data: { type: Modules.User.name, id: data.userId },
139
+ };
140
+ }
141
+
142
+ return response;
143
+ }
144
+ }