@qwickapps/server 1.4.0 → 1.5.1

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 (273) hide show
  1. package/CHANGELOG.md +507 -0
  2. package/README.md +9 -0
  3. package/dist/index.d.ts +2 -2
  4. package/dist/index.d.ts.map +1 -1
  5. package/dist/index.js +12 -2
  6. package/dist/index.js.map +1 -1
  7. package/dist/plugins/bans/bans-plugin.d.ts.map +1 -1
  8. package/dist/plugins/bans/bans-plugin.js +12 -3
  9. package/dist/plugins/bans/bans-plugin.js.map +1 -1
  10. package/dist/plugins/devices/__tests__/devices-plugin.test.d.ts +11 -0
  11. package/dist/plugins/devices/__tests__/devices-plugin.test.d.ts.map +1 -0
  12. package/dist/plugins/devices/__tests__/devices-plugin.test.js +410 -0
  13. package/dist/plugins/devices/__tests__/devices-plugin.test.js.map +1 -0
  14. package/dist/plugins/devices/__tests__/token-utils.test.d.ts +7 -0
  15. package/dist/plugins/devices/__tests__/token-utils.test.d.ts.map +1 -0
  16. package/dist/plugins/devices/__tests__/token-utils.test.js +197 -0
  17. package/dist/plugins/devices/__tests__/token-utils.test.js.map +1 -0
  18. package/dist/plugins/devices/adapters/compute-adapter.d.ts +36 -0
  19. package/dist/plugins/devices/adapters/compute-adapter.d.ts.map +1 -0
  20. package/dist/plugins/devices/adapters/compute-adapter.js +100 -0
  21. package/dist/plugins/devices/adapters/compute-adapter.js.map +1 -0
  22. package/dist/plugins/devices/adapters/index.d.ts +12 -0
  23. package/dist/plugins/devices/adapters/index.d.ts.map +1 -0
  24. package/dist/plugins/devices/adapters/index.js +10 -0
  25. package/dist/plugins/devices/adapters/index.js.map +1 -0
  26. package/dist/plugins/devices/adapters/mobile-adapter.d.ts +41 -0
  27. package/dist/plugins/devices/adapters/mobile-adapter.d.ts.map +1 -0
  28. package/dist/plugins/devices/adapters/mobile-adapter.js +131 -0
  29. package/dist/plugins/devices/adapters/mobile-adapter.js.map +1 -0
  30. package/dist/plugins/devices/devices-plugin.d.ts +70 -0
  31. package/dist/plugins/devices/devices-plugin.d.ts.map +1 -0
  32. package/dist/plugins/devices/devices-plugin.js +453 -0
  33. package/dist/plugins/devices/devices-plugin.js.map +1 -0
  34. package/dist/plugins/devices/index.d.ts +18 -0
  35. package/dist/plugins/devices/index.d.ts.map +1 -0
  36. package/dist/plugins/devices/index.js +18 -0
  37. package/dist/plugins/devices/index.js.map +1 -0
  38. package/dist/plugins/devices/stores/index.d.ts +9 -0
  39. package/dist/plugins/devices/stores/index.d.ts.map +1 -0
  40. package/dist/plugins/devices/stores/index.js +9 -0
  41. package/dist/plugins/devices/stores/index.js.map +1 -0
  42. package/dist/plugins/devices/stores/postgres-store.d.ts +26 -0
  43. package/dist/plugins/devices/stores/postgres-store.d.ts.map +1 -0
  44. package/dist/plugins/devices/stores/postgres-store.js +199 -0
  45. package/dist/plugins/devices/stores/postgres-store.js.map +1 -0
  46. package/dist/plugins/devices/token-utils.d.ts +100 -0
  47. package/dist/plugins/devices/token-utils.d.ts.map +1 -0
  48. package/dist/plugins/devices/token-utils.js +162 -0
  49. package/dist/plugins/devices/token-utils.js.map +1 -0
  50. package/dist/plugins/devices/types.d.ts +307 -0
  51. package/dist/plugins/devices/types.d.ts.map +1 -0
  52. package/dist/plugins/devices/types.js +10 -0
  53. package/dist/plugins/devices/types.js.map +1 -0
  54. package/dist/plugins/index.d.ts +14 -2
  55. package/dist/plugins/index.d.ts.map +1 -1
  56. package/dist/plugins/index.js +13 -1
  57. package/dist/plugins/index.js.map +1 -1
  58. package/dist/plugins/notifications/__tests__/notifications-manager.test.d.ts +5 -0
  59. package/dist/plugins/notifications/__tests__/notifications-manager.test.d.ts.map +1 -0
  60. package/dist/plugins/notifications/__tests__/notifications-manager.test.js +470 -0
  61. package/dist/plugins/notifications/__tests__/notifications-manager.test.js.map +1 -0
  62. package/dist/plugins/notifications/index.d.ts +71 -0
  63. package/dist/plugins/notifications/index.d.ts.map +1 -0
  64. package/dist/plugins/notifications/index.js +72 -0
  65. package/dist/plugins/notifications/index.js.map +1 -0
  66. package/dist/plugins/notifications/notifications-manager.d.ts +182 -0
  67. package/dist/plugins/notifications/notifications-manager.d.ts.map +1 -0
  68. package/dist/plugins/notifications/notifications-manager.js +610 -0
  69. package/dist/plugins/notifications/notifications-manager.js.map +1 -0
  70. package/dist/plugins/notifications/notifications-plugin.d.ts +83 -0
  71. package/dist/plugins/notifications/notifications-plugin.d.ts.map +1 -0
  72. package/dist/plugins/notifications/notifications-plugin.js +337 -0
  73. package/dist/plugins/notifications/notifications-plugin.js.map +1 -0
  74. package/dist/plugins/notifications/types.d.ts +164 -0
  75. package/dist/plugins/notifications/types.d.ts.map +1 -0
  76. package/dist/plugins/notifications/types.js +9 -0
  77. package/dist/plugins/notifications/types.js.map +1 -0
  78. package/dist/plugins/parental/__tests__/parental-plugin.test.d.ts +12 -0
  79. package/dist/plugins/parental/__tests__/parental-plugin.test.d.ts.map +1 -0
  80. package/dist/plugins/parental/__tests__/parental-plugin.test.js +349 -0
  81. package/dist/plugins/parental/__tests__/parental-plugin.test.js.map +1 -0
  82. package/dist/plugins/parental/adapters/index.d.ts +8 -0
  83. package/dist/plugins/parental/adapters/index.d.ts.map +1 -0
  84. package/dist/plugins/parental/adapters/index.js +7 -0
  85. package/dist/plugins/parental/adapters/index.js.map +1 -0
  86. package/dist/plugins/parental/adapters/kids-adapter.d.ts +24 -0
  87. package/dist/plugins/parental/adapters/kids-adapter.d.ts.map +1 -0
  88. package/dist/plugins/parental/adapters/kids-adapter.js +174 -0
  89. package/dist/plugins/parental/adapters/kids-adapter.js.map +1 -0
  90. package/dist/plugins/parental/index.d.ts +14 -0
  91. package/dist/plugins/parental/index.d.ts.map +1 -0
  92. package/dist/plugins/parental/index.js +15 -0
  93. package/dist/plugins/parental/index.js.map +1 -0
  94. package/dist/plugins/parental/parental-plugin.d.ts +88 -0
  95. package/dist/plugins/parental/parental-plugin.d.ts.map +1 -0
  96. package/dist/plugins/parental/parental-plugin.js +666 -0
  97. package/dist/plugins/parental/parental-plugin.js.map +1 -0
  98. package/dist/plugins/parental/stores/index.d.ts +7 -0
  99. package/dist/plugins/parental/stores/index.d.ts.map +1 -0
  100. package/dist/plugins/parental/stores/index.js +7 -0
  101. package/dist/plugins/parental/stores/index.js.map +1 -0
  102. package/dist/plugins/parental/stores/postgres-store.d.ts +10 -0
  103. package/dist/plugins/parental/stores/postgres-store.d.ts.map +1 -0
  104. package/dist/plugins/parental/stores/postgres-store.js +209 -0
  105. package/dist/plugins/parental/stores/postgres-store.js.map +1 -0
  106. package/dist/plugins/parental/types.d.ts +154 -0
  107. package/dist/plugins/parental/types.d.ts.map +1 -0
  108. package/dist/plugins/parental/types.js +10 -0
  109. package/dist/plugins/parental/types.js.map +1 -0
  110. package/dist/plugins/profiles/__tests__/profiles-plugin.test.d.ts +11 -0
  111. package/dist/plugins/profiles/__tests__/profiles-plugin.test.d.ts.map +1 -0
  112. package/dist/plugins/profiles/__tests__/profiles-plugin.test.js +243 -0
  113. package/dist/plugins/profiles/__tests__/profiles-plugin.test.js.map +1 -0
  114. package/dist/plugins/profiles/index.d.ts +12 -0
  115. package/dist/plugins/profiles/index.d.ts.map +1 -0
  116. package/dist/plugins/profiles/index.js +13 -0
  117. package/dist/plugins/profiles/index.js.map +1 -0
  118. package/dist/plugins/profiles/profiles-plugin.d.ts +71 -0
  119. package/dist/plugins/profiles/profiles-plugin.d.ts.map +1 -0
  120. package/dist/plugins/profiles/profiles-plugin.js +481 -0
  121. package/dist/plugins/profiles/profiles-plugin.js.map +1 -0
  122. package/dist/plugins/profiles/stores/index.d.ts +9 -0
  123. package/dist/plugins/profiles/stores/index.d.ts.map +1 -0
  124. package/dist/plugins/profiles/stores/index.js +9 -0
  125. package/dist/plugins/profiles/stores/index.js.map +1 -0
  126. package/dist/plugins/profiles/stores/postgres-store.d.ts +18 -0
  127. package/dist/plugins/profiles/stores/postgres-store.d.ts.map +1 -0
  128. package/dist/plugins/profiles/stores/postgres-store.js +310 -0
  129. package/dist/plugins/profiles/stores/postgres-store.js.map +1 -0
  130. package/dist/plugins/profiles/types.d.ts +289 -0
  131. package/dist/plugins/profiles/types.d.ts.map +1 -0
  132. package/dist/plugins/profiles/types.js +10 -0
  133. package/dist/plugins/profiles/types.js.map +1 -0
  134. package/dist/plugins/subscriptions/__tests__/subscriptions-plugin.test.d.ts +11 -0
  135. package/dist/plugins/subscriptions/__tests__/subscriptions-plugin.test.d.ts.map +1 -0
  136. package/dist/plugins/subscriptions/__tests__/subscriptions-plugin.test.js +305 -0
  137. package/dist/plugins/subscriptions/__tests__/subscriptions-plugin.test.js.map +1 -0
  138. package/dist/plugins/subscriptions/index.d.ts +12 -0
  139. package/dist/plugins/subscriptions/index.d.ts.map +1 -0
  140. package/dist/plugins/subscriptions/index.js +13 -0
  141. package/dist/plugins/subscriptions/index.js.map +1 -0
  142. package/dist/plugins/subscriptions/stores/index.d.ts +9 -0
  143. package/dist/plugins/subscriptions/stores/index.d.ts.map +1 -0
  144. package/dist/plugins/subscriptions/stores/index.js +9 -0
  145. package/dist/plugins/subscriptions/stores/index.js.map +1 -0
  146. package/dist/plugins/subscriptions/stores/postgres-store.d.ts +14 -0
  147. package/dist/plugins/subscriptions/stores/postgres-store.d.ts.map +1 -0
  148. package/dist/plugins/subscriptions/stores/postgres-store.js +359 -0
  149. package/dist/plugins/subscriptions/stores/postgres-store.js.map +1 -0
  150. package/dist/plugins/subscriptions/subscriptions-plugin.d.ts +82 -0
  151. package/dist/plugins/subscriptions/subscriptions-plugin.d.ts.map +1 -0
  152. package/dist/plugins/subscriptions/subscriptions-plugin.js +449 -0
  153. package/dist/plugins/subscriptions/subscriptions-plugin.js.map +1 -0
  154. package/dist/plugins/subscriptions/types.d.ts +308 -0
  155. package/dist/plugins/subscriptions/types.d.ts.map +1 -0
  156. package/dist/plugins/subscriptions/types.js +10 -0
  157. package/dist/plugins/subscriptions/types.js.map +1 -0
  158. package/dist/plugins/usage/__tests__/usage-plugin.test.d.ts +11 -0
  159. package/dist/plugins/usage/__tests__/usage-plugin.test.d.ts.map +1 -0
  160. package/dist/plugins/usage/__tests__/usage-plugin.test.js +218 -0
  161. package/dist/plugins/usage/__tests__/usage-plugin.test.js.map +1 -0
  162. package/dist/plugins/usage/index.d.ts +12 -0
  163. package/dist/plugins/usage/index.d.ts.map +1 -0
  164. package/dist/plugins/usage/index.js +13 -0
  165. package/dist/plugins/usage/index.js.map +1 -0
  166. package/dist/plugins/usage/stores/index.d.ts +9 -0
  167. package/dist/plugins/usage/stores/index.d.ts.map +1 -0
  168. package/dist/plugins/usage/stores/index.js +9 -0
  169. package/dist/plugins/usage/stores/index.js.map +1 -0
  170. package/dist/plugins/usage/stores/postgres-store.d.ts +14 -0
  171. package/dist/plugins/usage/stores/postgres-store.d.ts.map +1 -0
  172. package/dist/plugins/usage/stores/postgres-store.js +146 -0
  173. package/dist/plugins/usage/stores/postgres-store.js.map +1 -0
  174. package/dist/plugins/usage/types.d.ts +195 -0
  175. package/dist/plugins/usage/types.d.ts.map +1 -0
  176. package/dist/plugins/usage/types.js +10 -0
  177. package/dist/plugins/usage/types.js.map +1 -0
  178. package/dist/plugins/usage/usage-plugin.d.ts +51 -0
  179. package/dist/plugins/usage/usage-plugin.d.ts.map +1 -0
  180. package/dist/plugins/usage/usage-plugin.js +412 -0
  181. package/dist/plugins/usage/usage-plugin.js.map +1 -0
  182. package/dist/plugins/users/__tests__/postgres-store.test.d.ts +10 -0
  183. package/dist/plugins/users/__tests__/postgres-store.test.d.ts.map +1 -0
  184. package/dist/plugins/users/__tests__/postgres-store.test.js +229 -0
  185. package/dist/plugins/users/__tests__/postgres-store.test.js.map +1 -0
  186. package/dist/plugins/users/__tests__/users-plugin.test.js +3 -0
  187. package/dist/plugins/users/__tests__/users-plugin.test.js.map +1 -1
  188. package/dist/plugins/users/index.d.ts +2 -2
  189. package/dist/plugins/users/index.d.ts.map +1 -1
  190. package/dist/plugins/users/index.js +1 -1
  191. package/dist/plugins/users/index.js.map +1 -1
  192. package/dist/plugins/users/stores/postgres-store.d.ts.map +1 -1
  193. package/dist/plugins/users/stores/postgres-store.js +76 -0
  194. package/dist/plugins/users/stores/postgres-store.js.map +1 -1
  195. package/dist/plugins/users/types.d.ts +74 -6
  196. package/dist/plugins/users/types.d.ts.map +1 -1
  197. package/dist/plugins/users/users-plugin.d.ts +15 -1
  198. package/dist/plugins/users/users-plugin.d.ts.map +1 -1
  199. package/dist/plugins/users/users-plugin.js +29 -0
  200. package/dist/plugins/users/users-plugin.js.map +1 -1
  201. package/dist-ui/assets/index-CynOqPkb.js +469 -0
  202. package/dist-ui/assets/index-CynOqPkb.js.map +1 -0
  203. package/dist-ui/index.html +1 -1
  204. package/dist-ui-lib/api/controlPanelApi.d.ts +46 -0
  205. package/dist-ui-lib/components/StatCard.d.ts +16 -0
  206. package/dist-ui-lib/dashboard/widgets/NotificationsStatsWidget.d.ts +12 -0
  207. package/dist-ui-lib/dashboard/widgets/index.d.ts +1 -0
  208. package/dist-ui-lib/index.js +1822 -1611
  209. package/dist-ui-lib/index.js.map +1 -1
  210. package/dist-ui-lib/pages/NotificationsPage.d.ts +9 -0
  211. package/dist-ui-lib/utils/formatters.d.ts +19 -0
  212. package/package.json +3 -2
  213. package/src/index.ts +178 -0
  214. package/src/plugins/bans/bans-plugin.ts +15 -3
  215. package/src/plugins/devices/__tests__/devices-plugin.test.ts +551 -0
  216. package/src/plugins/devices/__tests__/token-utils.test.ts +264 -0
  217. package/src/plugins/devices/adapters/compute-adapter.ts +139 -0
  218. package/src/plugins/devices/adapters/index.ts +13 -0
  219. package/src/plugins/devices/adapters/mobile-adapter.ts +179 -0
  220. package/src/plugins/devices/devices-plugin.ts +538 -0
  221. package/src/plugins/devices/index.ts +69 -0
  222. package/src/plugins/devices/stores/index.ts +9 -0
  223. package/src/plugins/devices/stores/postgres-store.ts +304 -0
  224. package/src/plugins/devices/token-utils.ts +213 -0
  225. package/src/plugins/devices/types.ts +351 -0
  226. package/src/plugins/index.ts +218 -0
  227. package/src/plugins/notifications/__tests__/notifications-manager.test.ts +637 -0
  228. package/src/plugins/notifications/index.ts +91 -0
  229. package/src/plugins/notifications/notifications-manager.ts +773 -0
  230. package/src/plugins/notifications/notifications-plugin.ts +398 -0
  231. package/src/plugins/notifications/types.ts +207 -0
  232. package/src/plugins/parental/__tests__/parental-plugin.test.ts +465 -0
  233. package/src/plugins/parental/adapters/index.ts +8 -0
  234. package/src/plugins/parental/adapters/kids-adapter.ts +206 -0
  235. package/src/plugins/parental/index.ts +55 -0
  236. package/src/plugins/parental/parental-plugin.ts +759 -0
  237. package/src/plugins/parental/stores/index.ts +7 -0
  238. package/src/plugins/parental/stores/postgres-store.ts +304 -0
  239. package/src/plugins/parental/types.ts +180 -0
  240. package/src/plugins/profiles/__tests__/profiles-plugin.test.ts +321 -0
  241. package/src/plugins/profiles/index.ts +49 -0
  242. package/src/plugins/profiles/profiles-plugin.ts +546 -0
  243. package/src/plugins/profiles/stores/index.ts +9 -0
  244. package/src/plugins/profiles/stores/postgres-store.ts +439 -0
  245. package/src/plugins/profiles/types.ts +338 -0
  246. package/src/plugins/subscriptions/__tests__/subscriptions-plugin.test.ts +404 -0
  247. package/src/plugins/subscriptions/index.ts +51 -0
  248. package/src/plugins/subscriptions/stores/index.ts +9 -0
  249. package/src/plugins/subscriptions/stores/postgres-store.ts +482 -0
  250. package/src/plugins/subscriptions/subscriptions-plugin.ts +530 -0
  251. package/src/plugins/subscriptions/types.ts +355 -0
  252. package/src/plugins/usage/__tests__/usage-plugin.test.ts +288 -0
  253. package/src/plugins/usage/index.ts +39 -0
  254. package/src/plugins/usage/stores/index.ts +9 -0
  255. package/src/plugins/usage/stores/postgres-store.ts +213 -0
  256. package/src/plugins/usage/types.ts +222 -0
  257. package/src/plugins/usage/usage-plugin.ts +484 -0
  258. package/src/plugins/users/__tests__/postgres-store.test.ts +326 -0
  259. package/src/plugins/users/__tests__/users-plugin.test.ts +3 -0
  260. package/src/plugins/users/index.ts +6 -0
  261. package/src/plugins/users/stores/postgres-store.ts +104 -0
  262. package/src/plugins/users/types.ts +82 -6
  263. package/src/plugins/users/users-plugin.ts +37 -0
  264. package/ui/src/App.tsx +5 -1
  265. package/ui/src/api/controlPanelApi.ts +103 -6
  266. package/ui/src/components/StatCard.tsx +58 -0
  267. package/ui/src/dashboard/builtInWidgets.tsx +3 -1
  268. package/ui/src/dashboard/widgets/NotificationsStatsWidget.tsx +167 -0
  269. package/ui/src/dashboard/widgets/index.ts +1 -0
  270. package/ui/src/pages/NotificationsPage.tsx +417 -0
  271. package/ui/src/utils/formatters.ts +33 -0
  272. package/dist-ui/assets/index-D7DoZ9rL.js +0 -478
  273. package/dist-ui/assets/index-D7DoZ9rL.js.map +0 -1
@@ -0,0 +1,326 @@
1
+ /**
2
+ * PostgreSQL User Store Tests
3
+ *
4
+ * Unit tests for the PostgreSQL user store implementation,
5
+ * focusing on getByIdentifier() and linkIdentifiers() methods.
6
+ *
7
+ * Copyright (c) 2025 QwickApps.com. All rights reserved.
8
+ */
9
+
10
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
11
+ import { postgresUserStore } from '../stores/postgres-store.js';
12
+ import type { User, UserIdentifiers } from '../types.js';
13
+
14
+ // Mock user data
15
+ const mockUser: User = {
16
+ id: 'test-user-id-123',
17
+ email: 'test@example.com',
18
+ name: 'Test User',
19
+ external_id: 'auth0|abc123',
20
+ provider: 'auth0',
21
+ picture: 'https://example.com/avatar.jpg',
22
+ metadata: {
23
+ identifiers: {
24
+ auth0_user_id: 'auth0|abc123',
25
+ wp_user_id: 42,
26
+ keap_contact_id: 12345,
27
+ },
28
+ },
29
+ created_at: new Date('2025-01-01'),
30
+ updated_at: new Date('2025-01-01'),
31
+ last_login_at: new Date('2025-12-13'),
32
+ };
33
+
34
+ // Mock pg pool
35
+ const createMockPool = () => {
36
+ const mockQuery = vi.fn();
37
+ return {
38
+ query: mockQuery,
39
+ _mockQuery: mockQuery, // Expose for test assertions
40
+ };
41
+ };
42
+
43
+ describe('PostgreSQL User Store', () => {
44
+ let mockPool: ReturnType<typeof createMockPool>;
45
+ let store: ReturnType<typeof postgresUserStore>;
46
+
47
+ beforeEach(() => {
48
+ mockPool = createMockPool();
49
+ store = postgresUserStore({
50
+ pool: mockPool as any,
51
+ autoCreateTables: false, // Skip table creation in tests
52
+ });
53
+ });
54
+
55
+ describe('getByIdentifier()', () => {
56
+ it('should throw error when no identifier is provided (UT-001)', async () => {
57
+ const emptyIdentifiers: UserIdentifiers = {};
58
+
59
+ await expect(store.getByIdentifier(emptyIdentifiers)).rejects.toThrow(
60
+ 'At least one identifier must be provided'
61
+ );
62
+
63
+ // Should not make any DB queries
64
+ expect(mockPool._mockQuery).not.toHaveBeenCalled();
65
+ });
66
+
67
+ it('should find user by email first (priority 1) (UT-002)', async () => {
68
+ mockPool._mockQuery.mockResolvedValueOnce({ rows: [mockUser] });
69
+
70
+ const identifiers: UserIdentifiers = {
71
+ email: 'test@example.com',
72
+ auth0_user_id: 'auth0|abc123',
73
+ wp_user_id: 42,
74
+ };
75
+
76
+ const result = await store.getByIdentifier(identifiers);
77
+
78
+ expect(result).toEqual(mockUser);
79
+ expect(mockPool._mockQuery).toHaveBeenCalledTimes(1);
80
+ expect(mockPool._mockQuery).toHaveBeenCalledWith(
81
+ expect.stringContaining('LOWER(email) = LOWER($1)'),
82
+ ['test@example.com']
83
+ );
84
+ });
85
+
86
+ it('should find user by auth0_user_id if email not found (priority 2) (UT-003)', async () => {
87
+ // Email query returns no results
88
+ mockPool._mockQuery.mockResolvedValueOnce({ rows: [] });
89
+ // auth0_user_id query in metadata returns user
90
+ mockPool._mockQuery.mockResolvedValueOnce({ rows: [mockUser] });
91
+
92
+ const identifiers: UserIdentifiers = {
93
+ email: 'nonexistent@example.com',
94
+ auth0_user_id: 'auth0|abc123',
95
+ };
96
+
97
+ const result = await store.getByIdentifier(identifiers);
98
+
99
+ expect(result).toEqual(mockUser);
100
+ expect(mockPool._mockQuery).toHaveBeenCalledTimes(2);
101
+ expect(mockPool._mockQuery).toHaveBeenNthCalledWith(
102
+ 2,
103
+ expect.stringContaining("metadata->'identifiers'->>'auth0_user_id'"),
104
+ ['auth0|abc123']
105
+ );
106
+ });
107
+
108
+ it('should check legacy external_id for auth0 users (UT-004)', async () => {
109
+ // No email provided, so skip email check
110
+ // auth0_user_id in metadata returns no results
111
+ mockPool._mockQuery.mockResolvedValueOnce({ rows: [] });
112
+ // Legacy external_id query returns user
113
+ mockPool._mockQuery.mockResolvedValueOnce({ rows: [mockUser] });
114
+
115
+ const identifiers: UserIdentifiers = {
116
+ auth0_user_id: 'auth0|abc123',
117
+ };
118
+
119
+ const result = await store.getByIdentifier(identifiers);
120
+
121
+ expect(result).toEqual(mockUser);
122
+ expect(mockPool._mockQuery).toHaveBeenCalledTimes(2);
123
+ expect(mockPool._mockQuery).toHaveBeenNthCalledWith(
124
+ 2,
125
+ expect.stringContaining('external_id = $1'),
126
+ ['auth0|abc123']
127
+ );
128
+ });
129
+
130
+ it('should find user by wp_user_id (priority 3) (UT-005)', async () => {
131
+ // All higher priority queries return no results
132
+ mockPool._mockQuery.mockResolvedValueOnce({ rows: [] }); // email
133
+ mockPool._mockQuery.mockResolvedValueOnce({ rows: [] }); // auth0 metadata
134
+ mockPool._mockQuery.mockResolvedValueOnce({ rows: [] }); // auth0 external_id
135
+ mockPool._mockQuery.mockResolvedValueOnce({ rows: [mockUser] }); // wp_user_id
136
+
137
+ const identifiers: UserIdentifiers = {
138
+ email: 'nonexistent@example.com',
139
+ auth0_user_id: 'auth0|xyz',
140
+ wp_user_id: 42,
141
+ };
142
+
143
+ const result = await store.getByIdentifier(identifiers);
144
+
145
+ expect(result).toEqual(mockUser);
146
+ expect(mockPool._mockQuery).toHaveBeenNthCalledWith(
147
+ 4,
148
+ expect.stringContaining("(metadata->'identifiers'->>'wp_user_id')::int"),
149
+ [42]
150
+ );
151
+ });
152
+
153
+ it('should find user by keap_contact_id (priority 4) (UT-006)', async () => {
154
+ // All higher priority queries return no results
155
+ mockPool._mockQuery.mockResolvedValueOnce({ rows: [] }); // wp_user_id
156
+ mockPool._mockQuery.mockResolvedValueOnce({ rows: [mockUser] }); // keap_contact_id
157
+
158
+ const identifiers: UserIdentifiers = {
159
+ wp_user_id: 999,
160
+ keap_contact_id: 12345,
161
+ };
162
+
163
+ const result = await store.getByIdentifier(identifiers);
164
+
165
+ expect(result).toEqual(mockUser);
166
+ expect(mockPool._mockQuery).toHaveBeenNthCalledWith(
167
+ 2,
168
+ expect.stringContaining("(metadata->'identifiers'->>'keap_contact_id')::int"),
169
+ [12345]
170
+ );
171
+ });
172
+
173
+ it('should return null if no user found by any identifier (UT-007)', async () => {
174
+ // All queries return no results
175
+ mockPool._mockQuery.mockResolvedValue({ rows: [] });
176
+
177
+ const identifiers: UserIdentifiers = {
178
+ email: 'nonexistent@example.com',
179
+ auth0_user_id: 'auth0|nonexistent',
180
+ wp_user_id: 99999,
181
+ keap_contact_id: 99999,
182
+ };
183
+
184
+ const result = await store.getByIdentifier(identifiers);
185
+
186
+ expect(result).toBeNull();
187
+ });
188
+
189
+ it('should handle wp_user_id of 0 as valid identifier (UT-008)', async () => {
190
+ // Only wp_user_id provided (no email or auth0_user_id)
191
+ // So only 1 query for wp_user_id should be made
192
+ mockPool._mockQuery.mockResolvedValueOnce({ rows: [mockUser] });
193
+
194
+ const identifiers: UserIdentifiers = {
195
+ wp_user_id: 0,
196
+ };
197
+
198
+ const result = await store.getByIdentifier(identifiers);
199
+
200
+ expect(result).toEqual(mockUser);
201
+ expect(mockPool._mockQuery).toHaveBeenCalledTimes(1);
202
+ expect(mockPool._mockQuery).toHaveBeenCalledWith(
203
+ expect.stringContaining("(metadata->'identifiers'->>'wp_user_id')::int"),
204
+ [0]
205
+ );
206
+ });
207
+
208
+ it('should handle keap_contact_id of 0 as valid identifier (UT-009)', async () => {
209
+ // Only keap_contact_id provided (no email, auth0_user_id, or wp_user_id)
210
+ // So only 1 query for keap_contact_id should be made
211
+ mockPool._mockQuery.mockResolvedValueOnce({ rows: [mockUser] });
212
+
213
+ const identifiers: UserIdentifiers = {
214
+ keap_contact_id: 0,
215
+ };
216
+
217
+ const result = await store.getByIdentifier(identifiers);
218
+
219
+ expect(result).toEqual(mockUser);
220
+ expect(mockPool._mockQuery).toHaveBeenCalledTimes(1);
221
+ expect(mockPool._mockQuery).toHaveBeenCalledWith(
222
+ expect.stringContaining("(metadata->'identifiers'->>'keap_contact_id')::int"),
223
+ [0]
224
+ );
225
+ });
226
+ });
227
+
228
+ describe('linkIdentifiers()', () => {
229
+ it('should not make DB query when no identifiers provided (UT-010)', async () => {
230
+ await store.linkIdentifiers('user-123', {});
231
+
232
+ expect(mockPool._mockQuery).not.toHaveBeenCalled();
233
+ });
234
+
235
+ it('should update metadata with single identifier (UT-011)', async () => {
236
+ mockPool._mockQuery.mockResolvedValueOnce({ rows: [], rowCount: 1 });
237
+
238
+ await store.linkIdentifiers('user-123', {
239
+ wp_user_id: 42,
240
+ });
241
+
242
+ expect(mockPool._mockQuery).toHaveBeenCalledTimes(1);
243
+ expect(mockPool._mockQuery).toHaveBeenCalledWith(
244
+ expect.stringContaining('jsonb_set'),
245
+ [JSON.stringify({ wp_user_id: 42 }), 'user-123']
246
+ );
247
+ });
248
+
249
+ it('should update metadata with multiple identifiers (UT-012)', async () => {
250
+ mockPool._mockQuery.mockResolvedValueOnce({ rows: [], rowCount: 1 });
251
+
252
+ await store.linkIdentifiers('user-123', {
253
+ wp_user_id: 42,
254
+ auth0_user_id: 'auth0|abc123',
255
+ keap_contact_id: 12345,
256
+ });
257
+
258
+ expect(mockPool._mockQuery).toHaveBeenCalledTimes(1);
259
+ const [, args] = mockPool._mockQuery.mock.calls[0];
260
+ const identifiersJson = JSON.parse(args[0] as string);
261
+
262
+ expect(identifiersJson).toEqual({
263
+ wp_user_id: 42,
264
+ auth0_user_id: 'auth0|abc123',
265
+ keap_contact_id: 12345,
266
+ });
267
+ expect(args[1]).toBe('user-123');
268
+ });
269
+
270
+ it('should preserve existing identifiers (uses COALESCE) (UT-013)', async () => {
271
+ mockPool._mockQuery.mockResolvedValueOnce({ rows: [], rowCount: 1 });
272
+
273
+ await store.linkIdentifiers('user-123', {
274
+ wp_user_id: 42,
275
+ });
276
+
277
+ // Verify the query uses COALESCE to preserve existing values
278
+ expect(mockPool._mockQuery).toHaveBeenCalledWith(
279
+ expect.stringContaining("COALESCE(metadata->'identifiers', '{}'::jsonb)"),
280
+ expect.any(Array)
281
+ );
282
+ });
283
+
284
+ it('should handle undefined values correctly (UT-014)', async () => {
285
+ mockPool._mockQuery.mockResolvedValueOnce({ rows: [], rowCount: 1 });
286
+
287
+ await store.linkIdentifiers('user-123', {
288
+ wp_user_id: undefined,
289
+ auth0_user_id: 'auth0|abc123',
290
+ keap_contact_id: undefined,
291
+ });
292
+
293
+ expect(mockPool._mockQuery).toHaveBeenCalledTimes(1);
294
+ const [, args] = mockPool._mockQuery.mock.calls[0];
295
+ const identifiersJson = JSON.parse(args[0] as string);
296
+
297
+ // Should only include defined values
298
+ expect(identifiersJson).toEqual({
299
+ auth0_user_id: 'auth0|abc123',
300
+ });
301
+ });
302
+ });
303
+
304
+ describe('getByIds()', () => {
305
+ it('should return empty array for empty input (UT-015)', async () => {
306
+ const result = await store.getByIds([]);
307
+
308
+ expect(result).toEqual([]);
309
+ expect(mockPool._mockQuery).not.toHaveBeenCalled();
310
+ });
311
+
312
+ it('should batch query multiple users (UT-016)', async () => {
313
+ const mockUsers = [mockUser, { ...mockUser, id: 'user-2' }];
314
+ mockPool._mockQuery.mockResolvedValueOnce({ rows: mockUsers });
315
+
316
+ const result = await store.getByIds(['user-1', 'user-2']);
317
+
318
+ expect(result).toEqual(mockUsers);
319
+ expect(mockPool._mockQuery).toHaveBeenCalledTimes(1);
320
+ expect(mockPool._mockQuery).toHaveBeenCalledWith(
321
+ expect.stringContaining('id = ANY($1)'),
322
+ [['user-1', 'user-2']]
323
+ );
324
+ });
325
+ });
326
+ });
@@ -54,8 +54,11 @@ describe('Users Plugin', () => {
54
54
  name: 'mock',
55
55
  initialize: vi.fn().mockResolvedValue(undefined),
56
56
  getById: vi.fn().mockResolvedValue(mockUser),
57
+ getByIds: vi.fn().mockResolvedValue([mockUser]),
57
58
  getByEmail: vi.fn().mockResolvedValue(mockUser),
58
59
  getByExternalId: vi.fn().mockResolvedValue(null),
60
+ getByIdentifier: vi.fn().mockResolvedValue(mockUser),
61
+ linkIdentifiers: vi.fn().mockResolvedValue(undefined),
59
62
  create: vi.fn().mockResolvedValue(mockUser),
60
63
  update: vi.fn().mockResolvedValue(mockUser),
61
64
  delete: vi.fn().mockResolvedValue(true),
@@ -12,7 +12,10 @@ export {
12
12
  createUsersPlugin,
13
13
  getUserStore,
14
14
  getUserById,
15
+ getUsersByIds,
15
16
  getUserByEmail,
17
+ getUserByIdentifier,
18
+ linkUserIdentifiers,
16
19
  findOrCreateUser,
17
20
  buildUserInfo,
18
21
  } from './users-plugin.js';
@@ -32,6 +35,9 @@ export type {
32
35
  UsersUiConfig,
33
36
  UserInfo,
34
37
  UserSyncInput,
38
+ UserIdentifiers,
39
+ StoredIdentifiers,
40
+ UserProfileInput,
35
41
  } from './types.js';
36
42
 
37
43
  // Stores
@@ -17,6 +17,8 @@ import type {
17
17
  UserSearchParams,
18
18
  UserListResponse,
19
19
  PostgresUserStoreConfig,
20
+ UserIdentifiers,
21
+ StoredIdentifiers,
20
22
  } from '../types.js';
21
23
 
22
24
  // Pool interface (from pg package)
@@ -86,6 +88,12 @@ export function postgresUserStore(config: PostgresUserStoreConfig): UserStore {
86
88
  return (result.rows[0] as User) || null;
87
89
  },
88
90
 
91
+ async getByIds(ids: string[]): Promise<User[]> {
92
+ if (ids.length === 0) return [];
93
+ const result = await getPool().query(`SELECT * FROM ${usersTableFull} WHERE id = ANY($1)`, [ids]);
94
+ return result.rows as User[];
95
+ },
96
+
89
97
  async getByEmail(email: string): Promise<User | null> {
90
98
  const result = await getPool().query(`SELECT * FROM ${usersTableFull} WHERE LOWER(email) = LOWER($1)`, [
91
99
  email,
@@ -101,6 +109,102 @@ export function postgresUserStore(config: PostgresUserStoreConfig): UserStore {
101
109
  return (result.rows[0] as User) || null;
102
110
  },
103
111
 
112
+ async getByIdentifier(identifiers: UserIdentifiers): Promise<User | null> {
113
+ // Validate that at least one identifier is provided
114
+ const hasIdentifier =
115
+ identifiers.email ||
116
+ identifiers.auth0_user_id ||
117
+ identifiers.wp_user_id !== undefined ||
118
+ identifiers.keap_contact_id !== undefined;
119
+
120
+ if (!hasIdentifier) {
121
+ throw new Error('At least one identifier must be provided');
122
+ }
123
+
124
+ // Priority 1: Email (most reliable, always unique)
125
+ if (identifiers.email) {
126
+ const result = await getPool().query(
127
+ `SELECT * FROM ${usersTableFull} WHERE LOWER(email) = LOWER($1)`,
128
+ [identifiers.email]
129
+ );
130
+ if (result.rows[0]) return result.rows[0] as User;
131
+ }
132
+
133
+ // Priority 2: Auth0 user ID (stored in metadata.identifiers.auth0_user_id)
134
+ if (identifiers.auth0_user_id) {
135
+ const result = await getPool().query(
136
+ `SELECT * FROM ${usersTableFull}
137
+ WHERE metadata->'identifiers'->>'auth0_user_id' = $1`,
138
+ [identifiers.auth0_user_id]
139
+ );
140
+ if (result.rows[0]) return result.rows[0] as User;
141
+
142
+ // Also check legacy external_id field for backwards compatibility
143
+ const legacyResult = await getPool().query(
144
+ `SELECT * FROM ${usersTableFull} WHERE external_id = $1`,
145
+ [identifiers.auth0_user_id]
146
+ );
147
+ if (legacyResult.rows[0]) return legacyResult.rows[0] as User;
148
+ }
149
+
150
+ // Priority 3: WordPress user ID (stored in metadata.identifiers.wp_user_id)
151
+ // Note: Use !== undefined to allow 0 as a valid ID
152
+ if (identifiers.wp_user_id !== undefined) {
153
+ const result = await getPool().query(
154
+ `SELECT * FROM ${usersTableFull}
155
+ WHERE (metadata->'identifiers'->>'wp_user_id')::int = $1`,
156
+ [identifiers.wp_user_id]
157
+ );
158
+ if (result.rows[0]) return result.rows[0] as User;
159
+ }
160
+
161
+ // Priority 4: Keap contact ID (stored in metadata.identifiers.keap_contact_id)
162
+ // Note: Use !== undefined to allow 0 as a valid ID
163
+ if (identifiers.keap_contact_id !== undefined) {
164
+ const result = await getPool().query(
165
+ `SELECT * FROM ${usersTableFull}
166
+ WHERE (metadata->'identifiers'->>'keap_contact_id')::int = $1`,
167
+ [identifiers.keap_contact_id]
168
+ );
169
+ if (result.rows[0]) return result.rows[0] as User;
170
+ }
171
+
172
+ return null;
173
+ },
174
+
175
+ async linkIdentifiers(userId: string, identifiers: Partial<StoredIdentifiers>): Promise<void> {
176
+ // Build the identifiers object to merge
177
+ const identifiersToMerge: Record<string, unknown> = {};
178
+
179
+ if (identifiers.wp_user_id !== undefined) {
180
+ identifiersToMerge.wp_user_id = identifiers.wp_user_id;
181
+ }
182
+ if (identifiers.auth0_user_id !== undefined) {
183
+ identifiersToMerge.auth0_user_id = identifiers.auth0_user_id;
184
+ }
185
+ if (identifiers.keap_contact_id !== undefined) {
186
+ identifiersToMerge.keap_contact_id = identifiers.keap_contact_id;
187
+ }
188
+
189
+ if (Object.keys(identifiersToMerge).length === 0) {
190
+ return; // Nothing to update
191
+ }
192
+
193
+ // Merge new identifiers with existing ones using jsonb_set and coalesce
194
+ // This preserves existing identifiers while adding/updating new ones
195
+ await getPool().query(
196
+ `UPDATE ${usersTableFull}
197
+ SET metadata = jsonb_set(
198
+ COALESCE(metadata, '{}'::jsonb),
199
+ '{identifiers}',
200
+ COALESCE(metadata->'identifiers', '{}'::jsonb) || $1::jsonb
201
+ ),
202
+ updated_at = NOW()
203
+ WHERE id = $2`,
204
+ [JSON.stringify(identifiersToMerge), userId]
205
+ );
206
+ },
207
+
104
208
  async create(input: CreateUserInput): Promise<User> {
105
209
  const result = await getPool().query(
106
210
  `INSERT INTO ${usersTableFull} (email, name, external_id, provider, picture, metadata)
@@ -56,6 +56,32 @@ export interface UpdateUserInput {
56
56
  metadata?: Record<string, unknown>;
57
57
  }
58
58
 
59
+ /**
60
+ * Known user identifiers for multi-system lookup.
61
+ * Users can be looked up by any of these identifiers.
62
+ * At least one identifier must be provided for lookup.
63
+ */
64
+ export interface UserIdentifiers {
65
+ /** User's email address (most reliable) */
66
+ email?: string;
67
+ /** WordPress user ID */
68
+ wp_user_id?: number;
69
+ /** Auth0 user ID (sub claim, e.g., "auth0|123" or "google-oauth2|456") */
70
+ auth0_user_id?: string;
71
+ /** Keap CRM contact ID */
72
+ keap_contact_id?: number;
73
+ }
74
+
75
+ /**
76
+ * Stored identifiers in user metadata.
77
+ * These are stored in metadata.identifiers for multi-system linking.
78
+ */
79
+ export interface StoredIdentifiers {
80
+ wp_user_id?: number;
81
+ auth0_user_id?: string;
82
+ keap_contact_id?: number;
83
+ }
84
+
59
85
  /**
60
86
  * User search parameters
61
87
  */
@@ -102,6 +128,11 @@ export interface UserStore {
102
128
  */
103
129
  getById(id: string): Promise<User | null>;
104
130
 
131
+ /**
132
+ * Get multiple users by IDs (batch query)
133
+ */
134
+ getByIds(ids: string[]): Promise<User[]>;
135
+
105
136
  /**
106
137
  * Get a user by email
107
138
  */
@@ -112,6 +143,20 @@ export interface UserStore {
112
143
  */
113
144
  getByExternalId(externalId: string, provider: string): Promise<User | null>;
114
145
 
146
+ /**
147
+ * Get a user by any known identifier.
148
+ * Tries identifiers in priority order: email > auth0_user_id > wp_user_id > keap_contact_id.
149
+ * Returns the first match found.
150
+ */
151
+ getByIdentifier(identifiers: UserIdentifiers): Promise<User | null>;
152
+
153
+ /**
154
+ * Link external identifiers to a user.
155
+ * Stores identifiers in metadata.identifiers for future lookups.
156
+ * Merges with existing identifiers (doesn't overwrite unless value provided).
157
+ */
158
+ linkIdentifiers(userId: string, identifiers: Partial<StoredIdentifiers>): Promise<void>;
159
+
115
160
  /**
116
161
  * Create a new user
117
162
  */
@@ -231,17 +276,48 @@ export interface UserInfo {
231
276
  }
232
277
 
233
278
  /**
234
- * Input for POST /users/sync endpoint
279
+ * Input for POST /users/sync endpoint.
280
+ * Supports multiple identifiers for cross-system user linking.
235
281
  */
236
282
  export interface UserSyncInput {
237
- /** User's email address */
283
+ /** User's email address (required for user lookup/creation) */
238
284
  email: string;
239
- /** External provider ID (e.g., Auth0 user_id) */
240
- external_id: string;
241
- /** Provider name (e.g., 'auth0', 'google') */
242
- provider: string;
285
+ /** WordPress user ID (optional) */
286
+ wp_user_id?: number;
287
+ /** Auth0 user ID (sub claim) (optional) */
288
+ auth0_user_id?: string;
289
+ /** Keap CRM contact ID (optional - typically looked up by AuthKeaper) */
290
+ keap_contact_id?: number;
291
+ /** Provider name indicating source of sync request (e.g., 'auth0', 'wordpress') */
292
+ provider?: string;
243
293
  /** User's display name (optional) */
244
294
  name?: string;
245
295
  /** Profile picture URL (optional) */
246
296
  picture?: string;
297
+
298
+ // Legacy fields for backwards compatibility
299
+ /** @deprecated Use auth0_user_id instead */
300
+ external_id?: string;
301
+ }
302
+
303
+ /**
304
+ * Input for PUT /users/profile endpoint.
305
+ * At least one identifier is required for user lookup.
306
+ */
307
+ export interface UserProfileInput {
308
+ /** User's email address */
309
+ email?: string;
310
+ /** WordPress user ID */
311
+ wp_user_id?: number;
312
+ /** Auth0 user ID */
313
+ auth0_user_id?: string;
314
+ /** Profile data to update */
315
+ profile: {
316
+ name?: string;
317
+ first_name?: string;
318
+ last_name?: string;
319
+ phone?: string;
320
+ birthday?: string;
321
+ [key: string]: unknown;
322
+ };
247
323
  }
@@ -20,6 +20,8 @@ import type {
20
20
  UserSearchParams,
21
21
  UserInfo,
22
22
  UserSyncInput,
23
+ UserIdentifiers,
24
+ StoredIdentifiers,
23
25
  } from './types.js';
24
26
  // Import helpers from other plugins for buildUserInfo
25
27
  // Note: These imports are used dynamically based on registry.hasPlugin() checks
@@ -309,6 +311,16 @@ export async function getUserByEmail(email: string): Promise<User | null> {
309
311
  return currentStore.getByEmail(email);
310
312
  }
311
313
 
314
+ /**
315
+ * Get multiple users by IDs (batch query - more efficient than multiple getUserById calls)
316
+ */
317
+ export async function getUsersByIds(ids: string[]): Promise<User[]> {
318
+ if (!currentStore) {
319
+ throw new Error('Users plugin not initialized');
320
+ }
321
+ return currentStore.getByIds(ids);
322
+ }
323
+
312
324
  /**
313
325
  * Find or create a user from auth provider data
314
326
  */
@@ -416,3 +428,28 @@ export async function buildUserInfo(user: User, registry: PluginRegistry): Promi
416
428
  await Promise.all(promises);
417
429
  return info;
418
430
  }
431
+
432
+ /**
433
+ * Get a user by any known identifier.
434
+ * Tries identifiers in priority order: email > auth0_user_id > wp_user_id > keap_contact_id.
435
+ */
436
+ export async function getUserByIdentifier(identifiers: UserIdentifiers): Promise<User | null> {
437
+ if (!currentStore) {
438
+ throw new Error('Users plugin not initialized');
439
+ }
440
+ return currentStore.getByIdentifier(identifiers);
441
+ }
442
+
443
+ /**
444
+ * Link external identifiers to a user.
445
+ * Stores identifiers in metadata.identifiers for future lookups.
446
+ */
447
+ export async function linkUserIdentifiers(
448
+ userId: string,
449
+ identifiers: Partial<StoredIdentifiers>
450
+ ): Promise<void> {
451
+ if (!currentStore) {
452
+ throw new Error('Users plugin not initialized');
453
+ }
454
+ return currentStore.linkIdentifiers(userId, identifiers);
455
+ }
package/ui/src/App.tsx CHANGED
@@ -16,6 +16,7 @@ import { UsersPage } from './pages/UsersPage';
16
16
  import { EntitlementsPage } from './pages/EntitlementsPage';
17
17
  import { AuthPage } from './pages/AuthPage';
18
18
  import { RateLimitPage } from './pages/RateLimitPage';
19
+ import { NotificationsPage } from './pages/NotificationsPage';
19
20
  import { IntegrationsPage } from './pages/IntegrationsPage';
20
21
  import { PluginPage } from './pages/PluginPage';
21
22
  import { NotFoundPage } from './pages/NotFoundPage';
@@ -43,7 +44,7 @@ const builtInPluginNavItems: Record<string, NavigationItem> = {
43
44
  };
44
45
 
45
46
  // Routes that have dedicated page components
46
- const dedicatedRoutes = new Set(['/', '/plugins', '/logs', '/system', '/users', '/entitlements', '/auth', '/rate-limits', '/integrations']);
47
+ const dedicatedRoutes = new Set(['/', '/plugins', '/logs', '/system', '/users', '/entitlements', '/auth', '/rate-limits', '/notifications', '/integrations']);
47
48
 
48
49
  // Package version - injected at build time or fallback
49
50
  const SERVER_VERSION = '1.0.0';
@@ -215,6 +216,9 @@ export function App() {
215
216
  {registeredPlugins.has('rate-limit') && (
216
217
  <Route path="/rate-limits" element={<RateLimitPage />} />
217
218
  )}
219
+ {registeredPlugins.has('notifications') && (
220
+ <Route path="/notifications" element={<NotificationsPage />} />
221
+ )}
218
222
  {registeredPlugins.has('ai-proxy') && (
219
223
  <Route path="/integrations" element={<IntegrationsPage />} />
220
224
  )}