@qwickapps/server 1.4.0 → 1.5.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 (271) hide show
  1. package/dist/index.d.ts +2 -2
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +12 -2
  4. package/dist/index.js.map +1 -1
  5. package/dist/plugins/bans/bans-plugin.d.ts.map +1 -1
  6. package/dist/plugins/bans/bans-plugin.js +12 -3
  7. package/dist/plugins/bans/bans-plugin.js.map +1 -1
  8. package/dist/plugins/devices/__tests__/devices-plugin.test.d.ts +11 -0
  9. package/dist/plugins/devices/__tests__/devices-plugin.test.d.ts.map +1 -0
  10. package/dist/plugins/devices/__tests__/devices-plugin.test.js +410 -0
  11. package/dist/plugins/devices/__tests__/devices-plugin.test.js.map +1 -0
  12. package/dist/plugins/devices/__tests__/token-utils.test.d.ts +7 -0
  13. package/dist/plugins/devices/__tests__/token-utils.test.d.ts.map +1 -0
  14. package/dist/plugins/devices/__tests__/token-utils.test.js +197 -0
  15. package/dist/plugins/devices/__tests__/token-utils.test.js.map +1 -0
  16. package/dist/plugins/devices/adapters/compute-adapter.d.ts +36 -0
  17. package/dist/plugins/devices/adapters/compute-adapter.d.ts.map +1 -0
  18. package/dist/plugins/devices/adapters/compute-adapter.js +100 -0
  19. package/dist/plugins/devices/adapters/compute-adapter.js.map +1 -0
  20. package/dist/plugins/devices/adapters/index.d.ts +12 -0
  21. package/dist/plugins/devices/adapters/index.d.ts.map +1 -0
  22. package/dist/plugins/devices/adapters/index.js +10 -0
  23. package/dist/plugins/devices/adapters/index.js.map +1 -0
  24. package/dist/plugins/devices/adapters/mobile-adapter.d.ts +41 -0
  25. package/dist/plugins/devices/adapters/mobile-adapter.d.ts.map +1 -0
  26. package/dist/plugins/devices/adapters/mobile-adapter.js +131 -0
  27. package/dist/plugins/devices/adapters/mobile-adapter.js.map +1 -0
  28. package/dist/plugins/devices/devices-plugin.d.ts +70 -0
  29. package/dist/plugins/devices/devices-plugin.d.ts.map +1 -0
  30. package/dist/plugins/devices/devices-plugin.js +453 -0
  31. package/dist/plugins/devices/devices-plugin.js.map +1 -0
  32. package/dist/plugins/devices/index.d.ts +18 -0
  33. package/dist/plugins/devices/index.d.ts.map +1 -0
  34. package/dist/plugins/devices/index.js +18 -0
  35. package/dist/plugins/devices/index.js.map +1 -0
  36. package/dist/plugins/devices/stores/index.d.ts +9 -0
  37. package/dist/plugins/devices/stores/index.d.ts.map +1 -0
  38. package/dist/plugins/devices/stores/index.js +9 -0
  39. package/dist/plugins/devices/stores/index.js.map +1 -0
  40. package/dist/plugins/devices/stores/postgres-store.d.ts +26 -0
  41. package/dist/plugins/devices/stores/postgres-store.d.ts.map +1 -0
  42. package/dist/plugins/devices/stores/postgres-store.js +199 -0
  43. package/dist/plugins/devices/stores/postgres-store.js.map +1 -0
  44. package/dist/plugins/devices/token-utils.d.ts +100 -0
  45. package/dist/plugins/devices/token-utils.d.ts.map +1 -0
  46. package/dist/plugins/devices/token-utils.js +162 -0
  47. package/dist/plugins/devices/token-utils.js.map +1 -0
  48. package/dist/plugins/devices/types.d.ts +307 -0
  49. package/dist/plugins/devices/types.d.ts.map +1 -0
  50. package/dist/plugins/devices/types.js +10 -0
  51. package/dist/plugins/devices/types.js.map +1 -0
  52. package/dist/plugins/index.d.ts +14 -2
  53. package/dist/plugins/index.d.ts.map +1 -1
  54. package/dist/plugins/index.js +13 -1
  55. package/dist/plugins/index.js.map +1 -1
  56. package/dist/plugins/notifications/__tests__/notifications-manager.test.d.ts +5 -0
  57. package/dist/plugins/notifications/__tests__/notifications-manager.test.d.ts.map +1 -0
  58. package/dist/plugins/notifications/__tests__/notifications-manager.test.js +470 -0
  59. package/dist/plugins/notifications/__tests__/notifications-manager.test.js.map +1 -0
  60. package/dist/plugins/notifications/index.d.ts +71 -0
  61. package/dist/plugins/notifications/index.d.ts.map +1 -0
  62. package/dist/plugins/notifications/index.js +72 -0
  63. package/dist/plugins/notifications/index.js.map +1 -0
  64. package/dist/plugins/notifications/notifications-manager.d.ts +182 -0
  65. package/dist/plugins/notifications/notifications-manager.d.ts.map +1 -0
  66. package/dist/plugins/notifications/notifications-manager.js +610 -0
  67. package/dist/plugins/notifications/notifications-manager.js.map +1 -0
  68. package/dist/plugins/notifications/notifications-plugin.d.ts +83 -0
  69. package/dist/plugins/notifications/notifications-plugin.d.ts.map +1 -0
  70. package/dist/plugins/notifications/notifications-plugin.js +337 -0
  71. package/dist/plugins/notifications/notifications-plugin.js.map +1 -0
  72. package/dist/plugins/notifications/types.d.ts +164 -0
  73. package/dist/plugins/notifications/types.d.ts.map +1 -0
  74. package/dist/plugins/notifications/types.js +9 -0
  75. package/dist/plugins/notifications/types.js.map +1 -0
  76. package/dist/plugins/parental/__tests__/parental-plugin.test.d.ts +12 -0
  77. package/dist/plugins/parental/__tests__/parental-plugin.test.d.ts.map +1 -0
  78. package/dist/plugins/parental/__tests__/parental-plugin.test.js +349 -0
  79. package/dist/plugins/parental/__tests__/parental-plugin.test.js.map +1 -0
  80. package/dist/plugins/parental/adapters/index.d.ts +8 -0
  81. package/dist/plugins/parental/adapters/index.d.ts.map +1 -0
  82. package/dist/plugins/parental/adapters/index.js +7 -0
  83. package/dist/plugins/parental/adapters/index.js.map +1 -0
  84. package/dist/plugins/parental/adapters/kids-adapter.d.ts +24 -0
  85. package/dist/plugins/parental/adapters/kids-adapter.d.ts.map +1 -0
  86. package/dist/plugins/parental/adapters/kids-adapter.js +174 -0
  87. package/dist/plugins/parental/adapters/kids-adapter.js.map +1 -0
  88. package/dist/plugins/parental/index.d.ts +14 -0
  89. package/dist/plugins/parental/index.d.ts.map +1 -0
  90. package/dist/plugins/parental/index.js +15 -0
  91. package/dist/plugins/parental/index.js.map +1 -0
  92. package/dist/plugins/parental/parental-plugin.d.ts +88 -0
  93. package/dist/plugins/parental/parental-plugin.d.ts.map +1 -0
  94. package/dist/plugins/parental/parental-plugin.js +666 -0
  95. package/dist/plugins/parental/parental-plugin.js.map +1 -0
  96. package/dist/plugins/parental/stores/index.d.ts +7 -0
  97. package/dist/plugins/parental/stores/index.d.ts.map +1 -0
  98. package/dist/plugins/parental/stores/index.js +7 -0
  99. package/dist/plugins/parental/stores/index.js.map +1 -0
  100. package/dist/plugins/parental/stores/postgres-store.d.ts +10 -0
  101. package/dist/plugins/parental/stores/postgres-store.d.ts.map +1 -0
  102. package/dist/plugins/parental/stores/postgres-store.js +209 -0
  103. package/dist/plugins/parental/stores/postgres-store.js.map +1 -0
  104. package/dist/plugins/parental/types.d.ts +154 -0
  105. package/dist/plugins/parental/types.d.ts.map +1 -0
  106. package/dist/plugins/parental/types.js +10 -0
  107. package/dist/plugins/parental/types.js.map +1 -0
  108. package/dist/plugins/profiles/__tests__/profiles-plugin.test.d.ts +11 -0
  109. package/dist/plugins/profiles/__tests__/profiles-plugin.test.d.ts.map +1 -0
  110. package/dist/plugins/profiles/__tests__/profiles-plugin.test.js +243 -0
  111. package/dist/plugins/profiles/__tests__/profiles-plugin.test.js.map +1 -0
  112. package/dist/plugins/profiles/index.d.ts +12 -0
  113. package/dist/plugins/profiles/index.d.ts.map +1 -0
  114. package/dist/plugins/profiles/index.js +13 -0
  115. package/dist/plugins/profiles/index.js.map +1 -0
  116. package/dist/plugins/profiles/profiles-plugin.d.ts +71 -0
  117. package/dist/plugins/profiles/profiles-plugin.d.ts.map +1 -0
  118. package/dist/plugins/profiles/profiles-plugin.js +481 -0
  119. package/dist/plugins/profiles/profiles-plugin.js.map +1 -0
  120. package/dist/plugins/profiles/stores/index.d.ts +9 -0
  121. package/dist/plugins/profiles/stores/index.d.ts.map +1 -0
  122. package/dist/plugins/profiles/stores/index.js +9 -0
  123. package/dist/plugins/profiles/stores/index.js.map +1 -0
  124. package/dist/plugins/profiles/stores/postgres-store.d.ts +18 -0
  125. package/dist/plugins/profiles/stores/postgres-store.d.ts.map +1 -0
  126. package/dist/plugins/profiles/stores/postgres-store.js +310 -0
  127. package/dist/plugins/profiles/stores/postgres-store.js.map +1 -0
  128. package/dist/plugins/profiles/types.d.ts +289 -0
  129. package/dist/plugins/profiles/types.d.ts.map +1 -0
  130. package/dist/plugins/profiles/types.js +10 -0
  131. package/dist/plugins/profiles/types.js.map +1 -0
  132. package/dist/plugins/subscriptions/__tests__/subscriptions-plugin.test.d.ts +11 -0
  133. package/dist/plugins/subscriptions/__tests__/subscriptions-plugin.test.d.ts.map +1 -0
  134. package/dist/plugins/subscriptions/__tests__/subscriptions-plugin.test.js +305 -0
  135. package/dist/plugins/subscriptions/__tests__/subscriptions-plugin.test.js.map +1 -0
  136. package/dist/plugins/subscriptions/index.d.ts +12 -0
  137. package/dist/plugins/subscriptions/index.d.ts.map +1 -0
  138. package/dist/plugins/subscriptions/index.js +13 -0
  139. package/dist/plugins/subscriptions/index.js.map +1 -0
  140. package/dist/plugins/subscriptions/stores/index.d.ts +9 -0
  141. package/dist/plugins/subscriptions/stores/index.d.ts.map +1 -0
  142. package/dist/plugins/subscriptions/stores/index.js +9 -0
  143. package/dist/plugins/subscriptions/stores/index.js.map +1 -0
  144. package/dist/plugins/subscriptions/stores/postgres-store.d.ts +14 -0
  145. package/dist/plugins/subscriptions/stores/postgres-store.d.ts.map +1 -0
  146. package/dist/plugins/subscriptions/stores/postgres-store.js +359 -0
  147. package/dist/plugins/subscriptions/stores/postgres-store.js.map +1 -0
  148. package/dist/plugins/subscriptions/subscriptions-plugin.d.ts +82 -0
  149. package/dist/plugins/subscriptions/subscriptions-plugin.d.ts.map +1 -0
  150. package/dist/plugins/subscriptions/subscriptions-plugin.js +449 -0
  151. package/dist/plugins/subscriptions/subscriptions-plugin.js.map +1 -0
  152. package/dist/plugins/subscriptions/types.d.ts +308 -0
  153. package/dist/plugins/subscriptions/types.d.ts.map +1 -0
  154. package/dist/plugins/subscriptions/types.js +10 -0
  155. package/dist/plugins/subscriptions/types.js.map +1 -0
  156. package/dist/plugins/usage/__tests__/usage-plugin.test.d.ts +11 -0
  157. package/dist/plugins/usage/__tests__/usage-plugin.test.d.ts.map +1 -0
  158. package/dist/plugins/usage/__tests__/usage-plugin.test.js +218 -0
  159. package/dist/plugins/usage/__tests__/usage-plugin.test.js.map +1 -0
  160. package/dist/plugins/usage/index.d.ts +12 -0
  161. package/dist/plugins/usage/index.d.ts.map +1 -0
  162. package/dist/plugins/usage/index.js +13 -0
  163. package/dist/plugins/usage/index.js.map +1 -0
  164. package/dist/plugins/usage/stores/index.d.ts +9 -0
  165. package/dist/plugins/usage/stores/index.d.ts.map +1 -0
  166. package/dist/plugins/usage/stores/index.js +9 -0
  167. package/dist/plugins/usage/stores/index.js.map +1 -0
  168. package/dist/plugins/usage/stores/postgres-store.d.ts +14 -0
  169. package/dist/plugins/usage/stores/postgres-store.d.ts.map +1 -0
  170. package/dist/plugins/usage/stores/postgres-store.js +146 -0
  171. package/dist/plugins/usage/stores/postgres-store.js.map +1 -0
  172. package/dist/plugins/usage/types.d.ts +195 -0
  173. package/dist/plugins/usage/types.d.ts.map +1 -0
  174. package/dist/plugins/usage/types.js +10 -0
  175. package/dist/plugins/usage/types.js.map +1 -0
  176. package/dist/plugins/usage/usage-plugin.d.ts +51 -0
  177. package/dist/plugins/usage/usage-plugin.d.ts.map +1 -0
  178. package/dist/plugins/usage/usage-plugin.js +412 -0
  179. package/dist/plugins/usage/usage-plugin.js.map +1 -0
  180. package/dist/plugins/users/__tests__/postgres-store.test.d.ts +10 -0
  181. package/dist/plugins/users/__tests__/postgres-store.test.d.ts.map +1 -0
  182. package/dist/plugins/users/__tests__/postgres-store.test.js +229 -0
  183. package/dist/plugins/users/__tests__/postgres-store.test.js.map +1 -0
  184. package/dist/plugins/users/__tests__/users-plugin.test.js +3 -0
  185. package/dist/plugins/users/__tests__/users-plugin.test.js.map +1 -1
  186. package/dist/plugins/users/index.d.ts +2 -2
  187. package/dist/plugins/users/index.d.ts.map +1 -1
  188. package/dist/plugins/users/index.js +1 -1
  189. package/dist/plugins/users/index.js.map +1 -1
  190. package/dist/plugins/users/stores/postgres-store.d.ts.map +1 -1
  191. package/dist/plugins/users/stores/postgres-store.js +76 -0
  192. package/dist/plugins/users/stores/postgres-store.js.map +1 -1
  193. package/dist/plugins/users/types.d.ts +74 -6
  194. package/dist/plugins/users/types.d.ts.map +1 -1
  195. package/dist/plugins/users/users-plugin.d.ts +15 -1
  196. package/dist/plugins/users/users-plugin.d.ts.map +1 -1
  197. package/dist/plugins/users/users-plugin.js +29 -0
  198. package/dist/plugins/users/users-plugin.js.map +1 -1
  199. package/dist-ui/assets/index-CynOqPkb.js +469 -0
  200. package/dist-ui/assets/index-CynOqPkb.js.map +1 -0
  201. package/dist-ui/index.html +1 -1
  202. package/dist-ui-lib/api/controlPanelApi.d.ts +46 -0
  203. package/dist-ui-lib/components/StatCard.d.ts +16 -0
  204. package/dist-ui-lib/dashboard/widgets/NotificationsStatsWidget.d.ts +12 -0
  205. package/dist-ui-lib/dashboard/widgets/index.d.ts +1 -0
  206. package/dist-ui-lib/index.js +1822 -1611
  207. package/dist-ui-lib/index.js.map +1 -1
  208. package/dist-ui-lib/pages/NotificationsPage.d.ts +9 -0
  209. package/dist-ui-lib/utils/formatters.d.ts +19 -0
  210. package/package.json +1 -1
  211. package/src/index.ts +178 -0
  212. package/src/plugins/bans/bans-plugin.ts +15 -3
  213. package/src/plugins/devices/__tests__/devices-plugin.test.ts +551 -0
  214. package/src/plugins/devices/__tests__/token-utils.test.ts +264 -0
  215. package/src/plugins/devices/adapters/compute-adapter.ts +139 -0
  216. package/src/plugins/devices/adapters/index.ts +13 -0
  217. package/src/plugins/devices/adapters/mobile-adapter.ts +179 -0
  218. package/src/plugins/devices/devices-plugin.ts +538 -0
  219. package/src/plugins/devices/index.ts +69 -0
  220. package/src/plugins/devices/stores/index.ts +9 -0
  221. package/src/plugins/devices/stores/postgres-store.ts +304 -0
  222. package/src/plugins/devices/token-utils.ts +213 -0
  223. package/src/plugins/devices/types.ts +351 -0
  224. package/src/plugins/index.ts +218 -0
  225. package/src/plugins/notifications/__tests__/notifications-manager.test.ts +637 -0
  226. package/src/plugins/notifications/index.ts +91 -0
  227. package/src/plugins/notifications/notifications-manager.ts +773 -0
  228. package/src/plugins/notifications/notifications-plugin.ts +398 -0
  229. package/src/plugins/notifications/types.ts +207 -0
  230. package/src/plugins/parental/__tests__/parental-plugin.test.ts +465 -0
  231. package/src/plugins/parental/adapters/index.ts +8 -0
  232. package/src/plugins/parental/adapters/kids-adapter.ts +206 -0
  233. package/src/plugins/parental/index.ts +55 -0
  234. package/src/plugins/parental/parental-plugin.ts +759 -0
  235. package/src/plugins/parental/stores/index.ts +7 -0
  236. package/src/plugins/parental/stores/postgres-store.ts +304 -0
  237. package/src/plugins/parental/types.ts +180 -0
  238. package/src/plugins/profiles/__tests__/profiles-plugin.test.ts +321 -0
  239. package/src/plugins/profiles/index.ts +49 -0
  240. package/src/plugins/profiles/profiles-plugin.ts +546 -0
  241. package/src/plugins/profiles/stores/index.ts +9 -0
  242. package/src/plugins/profiles/stores/postgres-store.ts +439 -0
  243. package/src/plugins/profiles/types.ts +338 -0
  244. package/src/plugins/subscriptions/__tests__/subscriptions-plugin.test.ts +404 -0
  245. package/src/plugins/subscriptions/index.ts +51 -0
  246. package/src/plugins/subscriptions/stores/index.ts +9 -0
  247. package/src/plugins/subscriptions/stores/postgres-store.ts +482 -0
  248. package/src/plugins/subscriptions/subscriptions-plugin.ts +530 -0
  249. package/src/plugins/subscriptions/types.ts +355 -0
  250. package/src/plugins/usage/__tests__/usage-plugin.test.ts +288 -0
  251. package/src/plugins/usage/index.ts +39 -0
  252. package/src/plugins/usage/stores/index.ts +9 -0
  253. package/src/plugins/usage/stores/postgres-store.ts +213 -0
  254. package/src/plugins/usage/types.ts +222 -0
  255. package/src/plugins/usage/usage-plugin.ts +484 -0
  256. package/src/plugins/users/__tests__/postgres-store.test.ts +326 -0
  257. package/src/plugins/users/__tests__/users-plugin.test.ts +3 -0
  258. package/src/plugins/users/index.ts +6 -0
  259. package/src/plugins/users/stores/postgres-store.ts +104 -0
  260. package/src/plugins/users/types.ts +82 -6
  261. package/src/plugins/users/users-plugin.ts +37 -0
  262. package/ui/src/App.tsx +5 -1
  263. package/ui/src/api/controlPanelApi.ts +103 -6
  264. package/ui/src/components/StatCard.tsx +58 -0
  265. package/ui/src/dashboard/builtInWidgets.tsx +3 -1
  266. package/ui/src/dashboard/widgets/NotificationsStatsWidget.tsx +167 -0
  267. package/ui/src/dashboard/widgets/index.ts +1 -0
  268. package/ui/src/pages/NotificationsPage.tsx +417 -0
  269. package/ui/src/utils/formatters.ts +33 -0
  270. package/dist-ui/assets/index-D7DoZ9rL.js +0 -478
  271. package/dist-ui/assets/index-D7DoZ9rL.js.map +0 -1
@@ -0,0 +1,773 @@
1
+ /**
2
+ * Notifications Manager
3
+ *
4
+ * Core service that manages PostgreSQL LISTEN/NOTIFY and SSE client connections.
5
+ * Provides realtime event routing from database to connected clients.
6
+ *
7
+ * Architecture:
8
+ * - Single dedicated PostgreSQL connection for LISTEN (not from pool)
9
+ * - In-memory Map of SSE clients
10
+ * - Event routing based on device_id/user_id filters
11
+ * - Automatic reconnection with exponential backoff
12
+ * - Heartbeat system for connection health
13
+ *
14
+ * Copyright (c) 2025 QwickApps.com. All rights reserved.
15
+ */
16
+
17
+ import pg from 'pg';
18
+ import type { Response } from 'express';
19
+ import type {
20
+ SSEClient,
21
+ NotifyPayload,
22
+ NotificationsStats,
23
+ ConnectionHealth,
24
+ NotificationsManagerInterface,
25
+ NotificationsPluginConfig,
26
+ } from './types.js';
27
+ import type { Logger } from '../../core/types.js';
28
+
29
+ const { Client } = pg;
30
+
31
+ // Default configuration values
32
+ const DEFAULT_HEARTBEAT_INTERVAL = 60000; // 60 seconds
33
+ const DEFAULT_RECONNECT_MAX_ATTEMPTS = 10;
34
+ const DEFAULT_RECONNECT_BASE_DELAY = 1000; // 1 second
35
+ const DEFAULT_RECONNECT_MAX_DELAY = 60000; // 60 seconds
36
+ const CONNECTION_HEALTH_TIMEOUT = 30 * 60 * 1000; // 30 minutes (increased for low-traffic systems)
37
+ const DEFAULT_MAX_CLIENTS = 10000; // Maximum concurrent SSE clients
38
+
39
+ /**
40
+ * NotificationsManager - Singleton service for realtime notifications
41
+ */
42
+ export class NotificationsManager implements NotificationsManagerInterface {
43
+ private client: pg.Client | null = null;
44
+ private clients = new Map<string, SSEClient>();
45
+ private channels: string[];
46
+ private connectionString: string;
47
+ private logger: Logger;
48
+
49
+ // Configuration
50
+ private heartbeatInterval: number;
51
+ private heartbeatIncludeStatus: boolean;
52
+ private reconnectMaxAttempts: number;
53
+ private reconnectBaseDelay: number;
54
+ private reconnectMaxDelay: number;
55
+
56
+ // Connection state
57
+ private initialized = false;
58
+ private isReconnecting = false;
59
+ private isShuttingDown = false;
60
+ private reconnectAttempts = 0;
61
+ private reconnectTimer?: NodeJS.Timeout;
62
+
63
+ // Heartbeat
64
+ private heartbeatTimer?: NodeJS.Timeout;
65
+
66
+ // Statistics
67
+ private stats = {
68
+ totalConnections: 0,
69
+ currentConnections: 0,
70
+ eventsProcessed: 0,
71
+ eventsRouted: 0,
72
+ eventsParseFailed: 0,
73
+ eventsDroppedNoClients: 0,
74
+ reconnectionAttempts: 0,
75
+ lastReconnectionAt: undefined as Date | undefined,
76
+ };
77
+
78
+ // Max clients limit
79
+ private maxClients: number;
80
+
81
+ // Health tracking
82
+ private lastEventReceivedAt: number = Date.now();
83
+
84
+ constructor(
85
+ connectionString: string,
86
+ channels: string[],
87
+ config: NotificationsPluginConfig,
88
+ logger: Logger
89
+ ) {
90
+ this.connectionString = connectionString;
91
+ this.channels = channels;
92
+ this.logger = logger;
93
+
94
+ // Apply configuration with defaults
95
+ this.heartbeatInterval = config.heartbeat?.interval ?? DEFAULT_HEARTBEAT_INTERVAL;
96
+ this.heartbeatIncludeStatus = config.heartbeat?.includeStatus !== false;
97
+ this.reconnectMaxAttempts = config.reconnect?.maxAttempts ?? DEFAULT_RECONNECT_MAX_ATTEMPTS;
98
+ this.reconnectBaseDelay = config.reconnect?.baseDelay ?? DEFAULT_RECONNECT_BASE_DELAY;
99
+ this.reconnectMaxDelay = config.reconnect?.maxDelay ?? DEFAULT_RECONNECT_MAX_DELAY;
100
+ this.maxClients = DEFAULT_MAX_CLIENTS;
101
+ }
102
+
103
+ /**
104
+ * Helper to safely truncate IDs for logging
105
+ */
106
+ private truncateId(id: string | undefined): string {
107
+ return id ? id.substring(0, 8) : 'unknown';
108
+ }
109
+
110
+ /**
111
+ * Initialize the manager - connect to PostgreSQL and start LISTEN
112
+ */
113
+ async initialize(): Promise<void> {
114
+ if (this.initialized) {
115
+ return;
116
+ }
117
+
118
+ await this.connect();
119
+ }
120
+
121
+ /**
122
+ * Connect to PostgreSQL and set up LISTEN
123
+ */
124
+ private async connect(): Promise<void> {
125
+ if (this.isShuttingDown) {
126
+ this.logger.debug('Skip connection attempt - shutting down');
127
+ return;
128
+ }
129
+
130
+ try {
131
+ // Clean up existing connection if any
132
+ await this.cleanupConnection();
133
+
134
+ // Create new client
135
+ this.client = new Client({
136
+ connectionString: this.connectionString,
137
+ });
138
+
139
+ // Set up error handler before connecting
140
+ this.client.on('error', (err) => {
141
+ this.logger.error('PostgreSQL LISTEN connection error', { error: err.message });
142
+ this.handleConnectionError();
143
+ });
144
+
145
+ // Set up notification handler
146
+ this.client.on('notification', (msg) => {
147
+ this.handleNotification(msg);
148
+ });
149
+
150
+ // Connect
151
+ await this.client.connect();
152
+
153
+ // Subscribe to channels
154
+ for (const channel of this.channels) {
155
+ await this.client.query(`LISTEN ${this.sanitizeChannelName(channel)}`);
156
+ this.logger.debug(`Listening on channel: ${channel}`);
157
+ }
158
+
159
+ // Success
160
+ this.initialized = true;
161
+ this.reconnectAttempts = 0;
162
+ this.isReconnecting = false;
163
+
164
+ const clientMsg = this.clients.size > 0
165
+ ? ` - ${this.clients.size} client${this.clients.size !== 1 ? 's' : ''} connected`
166
+ : '';
167
+ this.logger.info(`Notifications service ready (${this.channels.length} channels)${clientMsg}`);
168
+ } catch (error) {
169
+ this.initialized = false;
170
+ const errorMsg = error instanceof Error ? error.message : String(error);
171
+ this.logger.error('Failed to connect to PostgreSQL for LISTEN', { error: errorMsg });
172
+ this.scheduleReconnect();
173
+ }
174
+ }
175
+
176
+ /**
177
+ * Handle PostgreSQL connection error
178
+ */
179
+ private handleConnectionError(): void {
180
+ this.initialized = false;
181
+
182
+ if (!this.isReconnecting && !this.isShuttingDown) {
183
+ this.scheduleReconnect();
184
+ }
185
+ }
186
+
187
+ /**
188
+ * Handle incoming notification from PostgreSQL
189
+ */
190
+ private handleNotification(msg: pg.Notification): void {
191
+ this.stats.eventsProcessed++;
192
+ this.lastEventReceivedAt = Date.now();
193
+
194
+ const channel = msg.channel;
195
+ const payloadStr = msg.payload;
196
+
197
+ if (!payloadStr) {
198
+ this.logger.debug('Received empty notification payload', { channel });
199
+ return;
200
+ }
201
+
202
+ let payload: NotifyPayload;
203
+ try {
204
+ payload = JSON.parse(payloadStr);
205
+ } catch {
206
+ this.stats.eventsParseFailed++;
207
+ this.logger.warn('Failed to parse notification payload as JSON', {
208
+ channel,
209
+ payload: payloadStr.substring(0, 100),
210
+ totalParseFailed: this.stats.eventsParseFailed,
211
+ });
212
+ return;
213
+ }
214
+
215
+ this.logger.debug('Received notification', {
216
+ channel,
217
+ deviceId: this.truncateId(payload.deviceId),
218
+ userId: this.truncateId(payload.userId),
219
+ eventType: payload.eventType,
220
+ });
221
+
222
+ // Route to matching clients
223
+ this.routeEvent(channel, payload);
224
+ }
225
+
226
+ /**
227
+ * Route event to matching SSE clients
228
+ */
229
+ private routeEvent(channel: string, payload: NotifyPayload): void {
230
+ const eventType = payload.eventType || channel;
231
+ const deviceId = payload.deviceId;
232
+ const userId = payload.userId;
233
+
234
+ const matchingClients = this.filterClients(deviceId, userId);
235
+
236
+ if (matchingClients.length === 0) {
237
+ this.stats.eventsDroppedNoClients++;
238
+ this.logger.debug('No SSE clients to receive event', {
239
+ channel,
240
+ eventType,
241
+ deviceId: this.truncateId(deviceId),
242
+ userId: this.truncateId(userId),
243
+ totalClients: this.clients.size,
244
+ });
245
+ return;
246
+ }
247
+
248
+ this.logger.debug(`Broadcasting ${eventType} to ${matchingClients.length} client(s)`);
249
+
250
+ for (const client of matchingClients) {
251
+ this.sendEvent(client, eventType, payload);
252
+ this.stats.eventsRouted++;
253
+ }
254
+ }
255
+
256
+ /**
257
+ * Filter clients based on device_id or user_id
258
+ */
259
+ private filterClients(deviceId: string | undefined, userId: string | undefined): SSEClient[] {
260
+ const matching: SSEClient[] = [];
261
+
262
+ for (const client of this.clients.values()) {
263
+ // Device-specific client matches device
264
+ if (client.deviceId && deviceId && client.deviceId === deviceId) {
265
+ matching.push(client);
266
+ }
267
+ // User-wide client matches user
268
+ else if (client.userId && userId && client.userId === userId) {
269
+ matching.push(client);
270
+ }
271
+ }
272
+
273
+ return matching;
274
+ }
275
+
276
+ /**
277
+ * Send SSE event to a client
278
+ */
279
+ private sendEvent(client: SSEClient, eventType: string, data: unknown): void {
280
+ try {
281
+ const eventData = JSON.stringify({ eventType, payload: data });
282
+ client.response.write(`event: ${eventType}\n`);
283
+ client.response.write(`data: ${eventData}\n\n`);
284
+ } catch {
285
+ // Client disconnected - will be cleaned up by close handler
286
+ this.logger.debug('Event send failed - client disconnected', {
287
+ clientId: client.id.substring(0, 8),
288
+ eventType,
289
+ });
290
+ }
291
+ }
292
+
293
+ /**
294
+ * Schedule reconnection with exponential backoff
295
+ */
296
+ private scheduleReconnect(): void {
297
+ if (this.isShuttingDown) {
298
+ this.isReconnecting = false;
299
+ return;
300
+ }
301
+
302
+ if (this.isReconnecting) {
303
+ this.logger.debug('Reconnection already in progress, skipping');
304
+ return;
305
+ }
306
+
307
+ if (this.reconnectAttempts >= this.reconnectMaxAttempts) {
308
+ this.logger.error(
309
+ `PostgreSQL LISTEN connection failed after ${this.reconnectMaxAttempts} attempts. ` +
310
+ 'Call forceReconnect() to retry.'
311
+ );
312
+ this.initialized = false;
313
+ this.isReconnecting = false;
314
+ return;
315
+ }
316
+
317
+ this.isReconnecting = true;
318
+ this.reconnectAttempts++;
319
+ this.stats.reconnectionAttempts++;
320
+
321
+ // Calculate delay with exponential backoff
322
+ const delay = Math.min(
323
+ this.reconnectBaseDelay * Math.pow(2, this.reconnectAttempts - 1),
324
+ this.reconnectMaxDelay
325
+ );
326
+
327
+ this.logger.info(
328
+ `Reconnecting to PostgreSQL in ${Math.round(delay / 1000)}s ` +
329
+ `(attempt ${this.reconnectAttempts}/${this.reconnectMaxAttempts})`
330
+ );
331
+
332
+ this.reconnectTimer = setTimeout(() => {
333
+ this.stats.lastReconnectionAt = new Date();
334
+ this.connect();
335
+ }, delay);
336
+ }
337
+
338
+ /**
339
+ * Clean up existing PostgreSQL connection
340
+ */
341
+ private async cleanupConnection(): Promise<void> {
342
+ if (!this.client) {
343
+ return;
344
+ }
345
+
346
+ try {
347
+ // Unlisten all channels
348
+ for (const channel of this.channels) {
349
+ try {
350
+ await this.client.query(`UNLISTEN ${this.sanitizeChannelName(channel)}`);
351
+ } catch {
352
+ // Ignore cleanup errors
353
+ }
354
+ }
355
+
356
+ await this.client.end();
357
+ } catch {
358
+ // Ignore cleanup errors
359
+ }
360
+
361
+ this.client = null;
362
+ }
363
+
364
+ /**
365
+ * Sanitize channel name to prevent SQL injection
366
+ */
367
+ private sanitizeChannelName(channel: string): string {
368
+ // Only allow alphanumeric and underscore
369
+ return channel.replace(/[^a-zA-Z0-9_]/g, '_');
370
+ }
371
+
372
+ // ===========================================================================
373
+ // Public API
374
+ // ===========================================================================
375
+
376
+ /**
377
+ * Register a new SSE client
378
+ * @returns true if registered, false if at capacity
379
+ */
380
+ registerClient(
381
+ id: string,
382
+ deviceId: string | undefined,
383
+ userId: string | undefined,
384
+ response: Response
385
+ ): boolean {
386
+ // Check capacity
387
+ if (this.clients.size >= this.maxClients) {
388
+ this.logger.warn('Max SSE clients reached, rejecting connection', {
389
+ maxClients: this.maxClients,
390
+ currentClients: this.clients.size,
391
+ });
392
+ return false;
393
+ }
394
+
395
+ const client: SSEClient = {
396
+ id,
397
+ deviceId,
398
+ userId,
399
+ response,
400
+ connectedAt: new Date(),
401
+ };
402
+
403
+ this.clients.set(id, client);
404
+ this.stats.totalConnections++;
405
+ this.stats.currentConnections++;
406
+
407
+ // Log connection
408
+ const identifier = deviceId
409
+ ? `Device ${this.truncateId(deviceId)}`
410
+ : userId
411
+ ? `User ${this.truncateId(userId)}`
412
+ : 'Unknown client';
413
+ this.logger.info(`${identifier} connected (${this.clients.size} active)`);
414
+
415
+ // Send initial connection event
416
+ this.sendEvent(client, 'connected', {
417
+ message: 'Connected to notifications service',
418
+ clientId: id,
419
+ timestamp: new Date().toISOString(),
420
+ });
421
+
422
+ // Set up cleanup on disconnect
423
+ response.on('close', () => {
424
+ this.unregisterClient(id);
425
+ });
426
+
427
+ // Start heartbeat if this is the first client
428
+ if (this.clients.size === 1 && !this.heartbeatTimer) {
429
+ this.startHeartbeat();
430
+ }
431
+
432
+ return true;
433
+ }
434
+
435
+ /**
436
+ * Unregister a client
437
+ */
438
+ private unregisterClient(id: string): void {
439
+ const client = this.clients.get(id);
440
+ if (!client) return;
441
+
442
+ this.clients.delete(id);
443
+ this.stats.currentConnections--;
444
+
445
+ const identifier = client.deviceId
446
+ ? `Device ${this.truncateId(client.deviceId)}`
447
+ : client.userId
448
+ ? `User ${this.truncateId(client.userId)}`
449
+ : 'Unknown client';
450
+ const durationMs = Date.now() - client.connectedAt.getTime();
451
+ const durationMin = Math.round(durationMs / 60000);
452
+ const durationDisplay = durationMin > 0 ? `${durationMin}m` : '<1m';
453
+
454
+ this.logger.info(`${identifier} disconnected after ${durationDisplay} (${this.clients.size} active)`);
455
+
456
+ // Stop heartbeat if no clients remain
457
+ if (this.clients.size === 0) {
458
+ this.stopHeartbeat();
459
+ }
460
+ }
461
+
462
+ /**
463
+ * Broadcast event to a specific device
464
+ */
465
+ broadcastToDevice(deviceId: string, eventType: string, payload: unknown): number {
466
+ let count = 0;
467
+ for (const client of this.clients.values()) {
468
+ if (client.deviceId === deviceId) {
469
+ this.sendEvent(client, eventType, payload);
470
+ count++;
471
+ }
472
+ }
473
+ return count;
474
+ }
475
+
476
+ /**
477
+ * Broadcast event to all devices for a user
478
+ */
479
+ broadcastToUser(userId: string, eventType: string, payload: unknown): number {
480
+ let count = 0;
481
+ for (const client of this.clients.values()) {
482
+ if (client.userId === userId) {
483
+ this.sendEvent(client, eventType, payload);
484
+ count++;
485
+ }
486
+ }
487
+ return count;
488
+ }
489
+
490
+ /**
491
+ * Broadcast to all connected clients
492
+ */
493
+ broadcastToAll(eventType: string, payload: unknown): number {
494
+ let count = 0;
495
+ for (const client of this.clients.values()) {
496
+ this.sendEvent(client, eventType, payload);
497
+ count++;
498
+ }
499
+ return count;
500
+ }
501
+
502
+ // ===========================================================================
503
+ // Heartbeat System
504
+ // ===========================================================================
505
+
506
+ /**
507
+ * Start heartbeat system
508
+ */
509
+ private startHeartbeat(): void {
510
+ this.logger.debug(`Starting heartbeat (${this.heartbeatInterval / 1000}s interval)`);
511
+
512
+ this.heartbeatTimer = setInterval(() => {
513
+ if (this.clients.size === 0) {
514
+ return;
515
+ }
516
+
517
+ const heartbeatData: Record<string, unknown> = {
518
+ timestamp: new Date().toISOString(),
519
+ };
520
+
521
+ if (this.heartbeatIncludeStatus) {
522
+ heartbeatData.server = {
523
+ status: this.initialized ? 'healthy' : 'degraded',
524
+ uptime: Math.round(process.uptime()),
525
+ clients: this.clients.size,
526
+ };
527
+ }
528
+
529
+ this.logger.debug(`Broadcasting heartbeat to ${this.clients.size} clients`);
530
+
531
+ for (const client of this.clients.values()) {
532
+ this.sendEvent(client, 'heartbeat', heartbeatData);
533
+ }
534
+ }, this.heartbeatInterval);
535
+ }
536
+
537
+ /**
538
+ * Stop heartbeat system
539
+ */
540
+ private stopHeartbeat(): void {
541
+ if (this.heartbeatTimer) {
542
+ clearInterval(this.heartbeatTimer);
543
+ this.heartbeatTimer = undefined;
544
+ this.logger.debug('Stopped heartbeat');
545
+ }
546
+ }
547
+
548
+ // ===========================================================================
549
+ // Client Management
550
+ // ===========================================================================
551
+
552
+ /**
553
+ * Get list of connected clients
554
+ */
555
+ getClients(): Array<{
556
+ id: string;
557
+ deviceId?: string;
558
+ userId?: string;
559
+ connectedAt: string;
560
+ durationMs: number;
561
+ }> {
562
+ const now = Date.now();
563
+ return Array.from(this.clients.values()).map((client) => ({
564
+ id: client.id,
565
+ deviceId: client.deviceId,
566
+ userId: client.userId,
567
+ connectedAt: client.connectedAt.toISOString(),
568
+ durationMs: now - client.connectedAt.getTime(),
569
+ }));
570
+ }
571
+
572
+ /**
573
+ * Disconnect a specific client by ID
574
+ * @param clientId - The client ID to disconnect
575
+ * @param disconnectedBy - Optional info about who initiated the disconnect (for audit logging)
576
+ * @returns true if client was found and disconnected, false otherwise
577
+ */
578
+ disconnectClient(
579
+ clientId: string,
580
+ disconnectedBy?: { userId?: string; email?: string; ip?: string }
581
+ ): boolean {
582
+ const client = this.clients.get(clientId);
583
+ if (!client) {
584
+ return false;
585
+ }
586
+
587
+ // Audit log with details about who disconnected the client
588
+ const disconnectInfo = disconnectedBy
589
+ ? ` by ${disconnectedBy.email || disconnectedBy.userId || disconnectedBy.ip || 'admin'}`
590
+ : '';
591
+ this.logger.info(
592
+ `Force disconnecting client ${this.truncateId(clientId)}${disconnectInfo}` +
593
+ (client.deviceId ? ` (device: ${this.truncateId(client.deviceId)})` : '') +
594
+ (client.userId ? ` (user: ${this.truncateId(client.userId)})` : '')
595
+ );
596
+
597
+ // Send disconnect event before closing
598
+ try {
599
+ this.sendEvent(client, 'disconnected', {
600
+ reason: 'Disconnected by administrator',
601
+ timestamp: new Date().toISOString(),
602
+ });
603
+ client.response.end();
604
+ } catch {
605
+ // Ignore errors - client may have already disconnected
606
+ }
607
+
608
+ // The unregisterClient will be called by the 'close' event handler
609
+ return true;
610
+ }
611
+
612
+ // ===========================================================================
613
+ // Statistics & Health
614
+ // ===========================================================================
615
+
616
+ /**
617
+ * Get current statistics
618
+ */
619
+ getStats(): NotificationsStats {
620
+ return {
621
+ ...this.stats,
622
+ clientsByType: {
623
+ device: Array.from(this.clients.values()).filter((c) => c.deviceId).length,
624
+ user: Array.from(this.clients.values()).filter((c) => c.userId && !c.deviceId).length,
625
+ },
626
+ connectionHealth: this.getConnectionHealth(),
627
+ };
628
+ }
629
+
630
+ /**
631
+ * Get connection health status
632
+ */
633
+ getConnectionHealth(): ConnectionHealth {
634
+ const now = Date.now();
635
+ const timeSinceLastEvent = now - this.lastEventReceivedAt;
636
+ const isHealthy = this.initialized && timeSinceLastEvent < CONNECTION_HEALTH_TIMEOUT;
637
+
638
+ return {
639
+ isConnected: this.initialized,
640
+ isHealthy,
641
+ lastEventAt: this.lastEventReceivedAt ? new Date(this.lastEventReceivedAt) : null,
642
+ timeSinceLastEvent,
643
+ channelCount: this.channels.length,
644
+ isReconnecting: this.isReconnecting,
645
+ reconnectAttempts: this.reconnectAttempts,
646
+ };
647
+ }
648
+
649
+ /**
650
+ * Force reconnection - useful for recovery
651
+ */
652
+ async forceReconnect(): Promise<void> {
653
+ this.logger.info('Force reconnection requested');
654
+ this.reconnectAttempts = 0;
655
+ this.isReconnecting = false;
656
+
657
+ if (this.reconnectTimer) {
658
+ clearTimeout(this.reconnectTimer);
659
+ this.reconnectTimer = undefined;
660
+ }
661
+
662
+ await this.cleanupConnection();
663
+ await this.connect();
664
+ }
665
+
666
+ /**
667
+ * Shutdown the manager
668
+ */
669
+ async shutdown(): Promise<void> {
670
+ this.isShuttingDown = true;
671
+
672
+ // Stop heartbeat
673
+ this.stopHeartbeat();
674
+
675
+ // Clear reconnect timer
676
+ if (this.reconnectTimer) {
677
+ clearTimeout(this.reconnectTimer);
678
+ this.reconnectTimer = undefined;
679
+ }
680
+
681
+ if (this.clients.size > 0) {
682
+ this.logger.info(`Shutting down notifications service (${this.clients.size} active connections)`);
683
+ }
684
+
685
+ // Close all SSE connections
686
+ for (const client of this.clients.values()) {
687
+ try {
688
+ client.response.end();
689
+ } catch {
690
+ // Ignore errors during shutdown
691
+ }
692
+ }
693
+ this.clients.clear();
694
+
695
+ // Clean up PostgreSQL connection
696
+ await this.cleanupConnection();
697
+
698
+ this.initialized = false;
699
+ this.logger.info('Notifications service stopped');
700
+ }
701
+ }
702
+
703
+ // =============================================================================
704
+ // Singleton Management
705
+ // =============================================================================
706
+
707
+ let managerInstance: NotificationsManager | null = null;
708
+
709
+ /**
710
+ * Set the notifications manager singleton
711
+ */
712
+ export function setNotificationsManager(manager: NotificationsManager | null): void {
713
+ managerInstance = manager;
714
+ }
715
+
716
+ /**
717
+ * Get the notifications manager singleton
718
+ * @throws Error if manager is not initialized
719
+ */
720
+ export function getNotificationsManager(): NotificationsManager {
721
+ if (!managerInstance) {
722
+ throw new Error(
723
+ 'NotificationsManager not initialized. Did you register the notifications plugin?'
724
+ );
725
+ }
726
+ return managerInstance;
727
+ }
728
+
729
+ /**
730
+ * Check if notifications manager is available
731
+ */
732
+ export function hasNotificationsManager(): boolean {
733
+ return managerInstance !== null;
734
+ }
735
+
736
+ // =============================================================================
737
+ // Helper Functions
738
+ // =============================================================================
739
+
740
+ /**
741
+ * Broadcast event to a specific device
742
+ * @returns Number of clients the event was sent to
743
+ */
744
+ export function broadcastToDevice(
745
+ deviceId: string,
746
+ eventType: string,
747
+ payload: unknown
748
+ ): number {
749
+ return getNotificationsManager().broadcastToDevice(deviceId, eventType, payload);
750
+ }
751
+
752
+ /**
753
+ * Broadcast event to all devices for a user
754
+ * @returns Number of clients the event was sent to
755
+ */
756
+ export function broadcastToUser(
757
+ userId: string,
758
+ eventType: string,
759
+ payload: unknown
760
+ ): number {
761
+ return getNotificationsManager().broadcastToUser(userId, eventType, payload);
762
+ }
763
+
764
+ /**
765
+ * Broadcast event to all connected clients
766
+ * @returns Number of clients the event was sent to
767
+ */
768
+ export function broadcastToAll(
769
+ eventType: string,
770
+ payload: unknown
771
+ ): number {
772
+ return getNotificationsManager().broadcastToAll(eventType, payload);
773
+ }