@omersx/bemms 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (335) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +203 -2
  3. package/drizzle.config.ts +11 -0
  4. package/next.config.ts +51 -0
  5. package/package.json +99 -3
  6. package/postcss.config.mjs +7 -0
  7. package/public/hospital_demo_devices.csv +32 -0
  8. package/public/icons/icon-192.png +0 -0
  9. package/public/icons/icon-512.png +0 -0
  10. package/public/icons/icon-maskable-512.png +0 -0
  11. package/public/manifest.webmanifest +31 -0
  12. package/public/sw.js +177 -0
  13. package/src/app/(auth)/layout.tsx +20 -0
  14. package/src/app/(auth)/login/page.tsx +135 -0
  15. package/src/app/(dashboard)/admin/audit-logs/page.tsx +178 -0
  16. package/src/app/(dashboard)/admin/data/page.tsx +37 -0
  17. package/src/app/(dashboard)/admin/departments/[deptId]/edit/page.tsx +206 -0
  18. package/src/app/(dashboard)/admin/departments/[deptId]/page.tsx +71 -0
  19. package/src/app/(dashboard)/admin/departments/new/page.tsx +6 -0
  20. package/src/app/(dashboard)/admin/departments/page.tsx +6 -0
  21. package/src/app/(dashboard)/admin/general/page.tsx +374 -0
  22. package/src/app/(dashboard)/admin/hospitals/[hospitalId]/edit/page.tsx +304 -0
  23. package/src/app/(dashboard)/admin/hospitals/[hospitalId]/page.tsx +94 -0
  24. package/src/app/(dashboard)/admin/hospitals/new/page.tsx +302 -0
  25. package/src/app/(dashboard)/admin/hospitals/page.tsx +6 -0
  26. package/src/app/(dashboard)/admin/layout.tsx +49 -0
  27. package/src/app/(dashboard)/admin/organizations/[orgId]/edit/page.tsx +172 -0
  28. package/src/app/(dashboard)/admin/organizations/[orgId]/page.tsx +82 -0
  29. package/src/app/(dashboard)/admin/organizations/new/page.tsx +150 -0
  30. package/src/app/(dashboard)/admin/organizations/page.tsx +6 -0
  31. package/src/app/(dashboard)/admin/page.tsx +258 -0
  32. package/src/app/(dashboard)/admin/roles/page.tsx +153 -0
  33. package/src/app/(dashboard)/admin/settings/page.tsx +69 -0
  34. package/src/app/(dashboard)/admin/users/[userId]/edit/page.tsx +153 -0
  35. package/src/app/(dashboard)/admin/users/[userId]/page.tsx +317 -0
  36. package/src/app/(dashboard)/admin/users/invite/page.tsx +3 -0
  37. package/src/app/(dashboard)/admin/users/new/page.tsx +5 -0
  38. package/src/app/(dashboard)/admin/users/page.tsx +23 -0
  39. package/src/app/(dashboard)/dashboard/page.tsx +462 -0
  40. package/src/app/(dashboard)/departments/[deptId]/page.tsx +39 -0
  41. package/src/app/(dashboard)/departments/new/page.tsx +330 -0
  42. package/src/app/(dashboard)/departments/page.tsx +52 -0
  43. package/src/app/(dashboard)/devices/[id]/edit/page.tsx +131 -0
  44. package/src/app/(dashboard)/devices/[id]/page.tsx +408 -0
  45. package/src/app/(dashboard)/devices/[id]/qr/page.tsx +54 -0
  46. package/src/app/(dashboard)/devices/new/page.tsx +382 -0
  47. package/src/app/(dashboard)/devices/page.tsx +80 -0
  48. package/src/app/(dashboard)/engineering-team/page.tsx +41 -0
  49. package/src/app/(dashboard)/layout.tsx +43 -0
  50. package/src/app/(dashboard)/maintenance/checklists/page.tsx +57 -0
  51. package/src/app/(dashboard)/maintenance/page.tsx +231 -0
  52. package/src/app/(dashboard)/maintenance/plans/page.tsx +142 -0
  53. package/src/app/(dashboard)/maintenance/tasks/[id]/page.tsx +997 -0
  54. package/src/app/(dashboard)/maintenance/tasks/page.tsx +119 -0
  55. package/src/app/(dashboard)/more/page.tsx +310 -0
  56. package/src/app/(dashboard)/my-department/page.tsx +151 -0
  57. package/src/app/(dashboard)/notifications/notification-list.tsx +103 -0
  58. package/src/app/(dashboard)/notifications/page.tsx +59 -0
  59. package/src/app/(dashboard)/profile/page.tsx +27 -0
  60. package/src/app/(dashboard)/reports/costs/page.tsx +127 -0
  61. package/src/app/(dashboard)/reports/history/page.tsx +105 -0
  62. package/src/app/(dashboard)/reports/inventory/page.tsx +116 -0
  63. package/src/app/(dashboard)/reports/maintenance/page.tsx +136 -0
  64. package/src/app/(dashboard)/reports/page.tsx +120 -0
  65. package/src/app/(dashboard)/reports/tickets/page.tsx +139 -0
  66. package/src/app/(dashboard)/reports/workload/page.tsx +135 -0
  67. package/src/app/(dashboard)/rounds/[id]/page.tsx +197 -0
  68. package/src/app/(dashboard)/rounds/history/page.tsx +82 -0
  69. package/src/app/(dashboard)/rounds/page.tsx +107 -0
  70. package/src/app/(dashboard)/scan/[code]/page.tsx +154 -0
  71. package/src/app/(dashboard)/scan/page.tsx +73 -0
  72. package/src/app/(dashboard)/team/page.tsx +42 -0
  73. package/src/app/(dashboard)/tickets/[id]/page.tsx +465 -0
  74. package/src/app/(dashboard)/tickets/create/page.tsx +722 -0
  75. package/src/app/(dashboard)/tickets/page.tsx +55 -0
  76. package/src/app/(dashboard)/tickets/triage/page.tsx +161 -0
  77. package/src/app/api/admin/data/export/route.ts +316 -0
  78. package/src/app/api/auth/[...nextauth]/route.ts +3 -0
  79. package/src/app/api/health/route.ts +46 -0
  80. package/src/app/api/notifications/sse/route.ts +91 -0
  81. package/src/app/api/notifications/subscribe/route.ts +54 -0
  82. package/src/app/api/notifications/vapid-key/route.ts +7 -0
  83. package/src/app/api/reports/export/route.ts +102 -0
  84. package/src/app/api/tickets/[id]/attachments/route.ts +129 -0
  85. package/src/app/api/tickets/[id]/live-sync/route.ts +52 -0
  86. package/src/app/error.tsx +54 -0
  87. package/src/app/globals.css +89 -0
  88. package/src/app/layout.tsx +59 -0
  89. package/src/app/not-found.tsx +32 -0
  90. package/src/app/page.tsx +5 -0
  91. package/src/components/admin/add-department-user-dialog.tsx +759 -0
  92. package/src/components/admin/add-engineer-dialog.tsx +773 -0
  93. package/src/components/admin/add-user-type-dialog.tsx +89 -0
  94. package/src/components/admin/audit-log-export.tsx +113 -0
  95. package/src/components/admin/audit-log-filters.tsx +255 -0
  96. package/src/components/admin/audit-log-table.tsx +383 -0
  97. package/src/components/admin/data-management/danger-zone.tsx +301 -0
  98. package/src/components/admin/data-management/data-management-tabs.tsx +61 -0
  99. package/src/components/admin/data-management/export-center.tsx +301 -0
  100. package/src/components/admin/data-management/import-wizard.tsx +558 -0
  101. package/src/components/admin/settings-forms/features-form.tsx +115 -0
  102. package/src/components/admin/settings-forms/health-panel.tsx +182 -0
  103. package/src/components/admin/settings-forms/localization-form.tsx +203 -0
  104. package/src/components/admin/settings-forms/retention-form.tsx +164 -0
  105. package/src/components/admin/settings-forms/security-form.tsx +186 -0
  106. package/src/components/admin/system-settings-tabs.tsx +88 -0
  107. package/src/components/admin/user-role-dialog.tsx +293 -0
  108. package/src/components/admin/user-status-dialog.tsx +166 -0
  109. package/src/components/admin/users-page-client.tsx +514 -0
  110. package/src/components/dashboard/action-buttons.tsx +93 -0
  111. package/src/components/dashboard/admin-alerts.tsx +86 -0
  112. package/src/components/dashboard/availability-alerts.tsx +65 -0
  113. package/src/components/dashboard/maintenance-due-list.tsx +87 -0
  114. package/src/components/dashboard/round-widget.tsx +110 -0
  115. package/src/components/dashboard/stat-card.tsx +38 -0
  116. package/src/components/dashboard/ticket-list-compact.tsx +86 -0
  117. package/src/components/departments/department-cards-view.tsx +244 -0
  118. package/src/components/departments/department-detail-view.tsx +467 -0
  119. package/src/components/departments/department-visuals.tsx +190 -0
  120. package/src/components/departments/my-department-view.tsx +333 -0
  121. package/src/components/devices/change-status-dialog.tsx +146 -0
  122. package/src/components/devices/device-action-buttons.tsx +125 -0
  123. package/src/components/devices/device-availability-banner.tsx +59 -0
  124. package/src/components/devices/device-badges.tsx +55 -0
  125. package/src/components/devices/device-documents-tab.tsx +93 -0
  126. package/src/components/devices/device-maintenance-tab.tsx +113 -0
  127. package/src/components/devices/device-mini-qr.tsx +109 -0
  128. package/src/components/devices/device-qr-card.tsx +187 -0
  129. package/src/components/devices/device-signatures-tab.tsx +607 -0
  130. package/src/components/devices/device-tickets-tab.tsx +139 -0
  131. package/src/components/devices/device-timelines.tsx +124 -0
  132. package/src/components/devices/qr-camera-scanner.tsx +167 -0
  133. package/src/components/devices/qr-code-display.tsx +139 -0
  134. package/src/components/devices/quick-add-category-dialog.tsx +135 -0
  135. package/src/components/devices/quick-add-manufacturer-dialog.tsx +212 -0
  136. package/src/components/devices/transfer-device-dialog.tsx +190 -0
  137. package/src/components/layout/header.tsx +80 -0
  138. package/src/components/layout/mobile-nav.tsx +91 -0
  139. package/src/components/layout/more-item-badge.tsx +15 -0
  140. package/src/components/layout/nav-badge.tsx +72 -0
  141. package/src/components/layout/sidebar.tsx +297 -0
  142. package/src/components/maintenance/maintenance-badges.tsx +579 -0
  143. package/src/components/maintenance/maintenance-nav.tsx +107 -0
  144. package/src/components/maintenance/parts-costs-manager.tsx +909 -0
  145. package/src/components/maintenance/signature-card.tsx +413 -0
  146. package/src/components/maintenance/task-action-bar.tsx +955 -0
  147. package/src/components/notifications/notification-bell.tsx +367 -0
  148. package/src/components/notifications/notification-item.tsx +93 -0
  149. package/src/components/notifications/notification-preferences.tsx +182 -0
  150. package/src/components/profile/user-profile-view.tsx +626 -0
  151. package/src/components/providers/nav-counts-provider.tsx +235 -0
  152. package/src/components/providers/session-provider.tsx +7 -0
  153. package/src/components/providers/theme-provider.tsx +124 -0
  154. package/src/components/pwa/install-prompt.tsx +82 -0
  155. package/src/components/pwa/sw-register.tsx +43 -0
  156. package/src/components/reports/export-button.tsx +62 -0
  157. package/src/components/reports/filter-chips.tsx +47 -0
  158. package/src/components/reports/summary-cards.tsx +40 -0
  159. package/src/components/reports/transparency-header.tsx +54 -0
  160. package/src/components/rounds/department-visit-sheet.tsx +199 -0
  161. package/src/components/rounds/mention-textarea.tsx +187 -0
  162. package/src/components/rounds/round-calendar.tsx +233 -0
  163. package/src/components/rounds/round-department-list.tsx +281 -0
  164. package/src/components/rounds/round-history-client.tsx +218 -0
  165. package/src/components/rounds/start-round-card.tsx +184 -0
  166. package/src/components/shared/confirm-dialog.tsx +67 -0
  167. package/src/components/shared/data-table.tsx +104 -0
  168. package/src/components/shared/page-header.tsx +20 -0
  169. package/src/components/shared/sign-out-button.tsx +52 -0
  170. package/src/components/shared/status-badge.tsx +42 -0
  171. package/src/components/shared/table-search.tsx +55 -0
  172. package/src/components/signatures/sign-action-dialog.tsx +253 -0
  173. package/src/components/signatures/signature-history.tsx +121 -0
  174. package/src/components/signatures/signature-status-panel.tsx +164 -0
  175. package/src/components/team/department-coverage.tsx +141 -0
  176. package/src/components/team/edit-engineer-dialog.tsx +242 -0
  177. package/src/components/team/engineer-card.tsx +180 -0
  178. package/src/components/team/engineer-details-dialog.tsx +432 -0
  179. package/src/components/team/schedule-calendar.tsx +204 -0
  180. package/src/components/team/staff-engineer-team-view.tsx +368 -0
  181. package/src/components/team/team-header-stats.tsx +88 -0
  182. package/src/components/team/team-page-client.tsx +216 -0
  183. package/src/components/theme/theme-selector-card.tsx +63 -0
  184. package/src/components/theme/theme-toggle.tsx +65 -0
  185. package/src/components/tickets/staff-status-label.tsx +58 -0
  186. package/src/components/tickets/staff-ticket-card.tsx +109 -0
  187. package/src/components/tickets/staff-ticket-progress-tracker.tsx +405 -0
  188. package/src/components/tickets/staff-tickets-view.tsx +182 -0
  189. package/src/components/tickets/ticket-action-buttons.tsx +873 -0
  190. package/src/components/tickets/ticket-auto-refresh.tsx +197 -0
  191. package/src/components/tickets/ticket-badges.tsx +43 -0
  192. package/src/components/tickets/ticket-card.tsx +197 -0
  193. package/src/components/tickets/ticket-cards-view.tsx +395 -0
  194. package/src/components/tickets/ticket-comment-section.tsx +684 -0
  195. package/src/components/tickets/ticket-dispatch-status.tsx +184 -0
  196. package/src/components/tickets/ticket-list-auto-refresh.tsx +55 -0
  197. package/src/components/tickets/ticket-reporter-profile.tsx +235 -0
  198. package/src/components/tickets/ticket-signatures-panel.tsx +32 -0
  199. package/src/components/tickets/ticket-timeline.tsx +131 -0
  200. package/src/components/tickets/ticket-triage-panel.tsx +124 -0
  201. package/src/components/tickets/tickets-nav.tsx +107 -0
  202. package/src/components/ui/avatar.tsx +43 -0
  203. package/src/components/ui/badge.tsx +34 -0
  204. package/src/components/ui/button.tsx +58 -0
  205. package/src/components/ui/card.tsx +77 -0
  206. package/src/components/ui/checkbox.tsx +31 -0
  207. package/src/components/ui/dialog.tsx +123 -0
  208. package/src/components/ui/dropdown-menu.tsx +126 -0
  209. package/src/components/ui/form.tsx +179 -0
  210. package/src/components/ui/input.tsx +24 -0
  211. package/src/components/ui/label.tsx +27 -0
  212. package/src/components/ui/popover.tsx +34 -0
  213. package/src/components/ui/progress.tsx +36 -0
  214. package/src/components/ui/scroll-area.tsx +47 -0
  215. package/src/components/ui/select.tsx +160 -0
  216. package/src/components/ui/separator.tsx +25 -0
  217. package/src/components/ui/sheet.tsx +131 -0
  218. package/src/components/ui/switch.tsx +30 -0
  219. package/src/components/ui/table.tsx +121 -0
  220. package/src/components/ui/tabs.tsx +62 -0
  221. package/src/components/ui/textarea.tsx +23 -0
  222. package/src/components/ui/tooltip.tsx +27 -0
  223. package/src/lib/actions/__tests__/devices.test.ts +176 -0
  224. package/src/lib/actions/__tests__/signatures.test.ts +303 -0
  225. package/src/lib/actions/__tests__/tickets.test.ts +243 -0
  226. package/src/lib/actions/audit-logs.ts +298 -0
  227. package/src/lib/actions/checklists.ts +429 -0
  228. package/src/lib/actions/dashboard.ts +358 -0
  229. package/src/lib/actions/data-management.ts +723 -0
  230. package/src/lib/actions/departments.ts +369 -0
  231. package/src/lib/actions/device-categories.ts +117 -0
  232. package/src/lib/actions/devices.ts +748 -0
  233. package/src/lib/actions/engineer-rounds.ts +973 -0
  234. package/src/lib/actions/engineers.ts +285 -0
  235. package/src/lib/actions/hospitals.ts +148 -0
  236. package/src/lib/actions/locations.ts +115 -0
  237. package/src/lib/actions/maintenance-plans.ts +316 -0
  238. package/src/lib/actions/maintenance-tasks.ts +415 -0
  239. package/src/lib/actions/manufacturers.ts +132 -0
  240. package/src/lib/actions/nav-counts.ts +144 -0
  241. package/src/lib/actions/notifications.ts +136 -0
  242. package/src/lib/actions/organizations.ts +99 -0
  243. package/src/lib/actions/qr.ts +147 -0
  244. package/src/lib/actions/report-history.ts +77 -0
  245. package/src/lib/actions/reports.ts +192 -0
  246. package/src/lib/actions/roles.ts +32 -0
  247. package/src/lib/actions/signature-auth.ts +377 -0
  248. package/src/lib/actions/signatures.ts +532 -0
  249. package/src/lib/actions/system-settings.ts +274 -0
  250. package/src/lib/actions/tickets.ts +1445 -0
  251. package/src/lib/actions/users.ts +740 -0
  252. package/src/lib/audit/index.ts +103 -0
  253. package/src/lib/auth/__tests__/rbac.test.ts +33 -0
  254. package/src/lib/auth/index.ts +338 -0
  255. package/src/lib/auth/password.ts +10 -0
  256. package/src/lib/auth/rbac.ts +103 -0
  257. package/src/lib/auth/types.ts +57 -0
  258. package/src/lib/constants/departments.ts +16 -0
  259. package/src/lib/constants/manufacturers.ts +32 -0
  260. package/src/lib/db/index.ts +23 -0
  261. package/src/lib/db/schema/audit-logs.ts +42 -0
  262. package/src/lib/db/schema/departments.ts +39 -0
  263. package/src/lib/db/schema/device-categories.ts +38 -0
  264. package/src/lib/db/schema/devices.ts +195 -0
  265. package/src/lib/db/schema/engineer-profiles.ts +63 -0
  266. package/src/lib/db/schema/engineer-rounds.ts +115 -0
  267. package/src/lib/db/schema/enums.ts +148 -0
  268. package/src/lib/db/schema/hospitals.ts +34 -0
  269. package/src/lib/db/schema/index.ts +24 -0
  270. package/src/lib/db/schema/locations.ts +46 -0
  271. package/src/lib/db/schema/maintenance-plans.ts +99 -0
  272. package/src/lib/db/schema/maintenance-records.ts +161 -0
  273. package/src/lib/db/schema/maintenance-tasks.ts +196 -0
  274. package/src/lib/db/schema/manufacturers.ts +60 -0
  275. package/src/lib/db/schema/notifications.ts +48 -0
  276. package/src/lib/db/schema/organizations.ts +24 -0
  277. package/src/lib/db/schema/report-history.ts +28 -0
  278. package/src/lib/db/schema/roles.ts +59 -0
  279. package/src/lib/db/schema/sessions.ts +46 -0
  280. package/src/lib/db/schema/signature-events.ts +48 -0
  281. package/src/lib/db/schema/signature-policies.ts +60 -0
  282. package/src/lib/db/schema/signatures.ts +87 -0
  283. package/src/lib/db/schema/tickets.ts +169 -0
  284. package/src/lib/db/schema/user-assignments.ts +85 -0
  285. package/src/lib/db/schema/users.ts +39 -0
  286. package/src/lib/db/seed.ts +131 -0
  287. package/src/lib/logger/__tests__/logger.test.ts +154 -0
  288. package/src/lib/logger/formatters.ts +159 -0
  289. package/src/lib/logger/index.ts +184 -0
  290. package/src/lib/logger/types.ts +48 -0
  291. package/src/lib/notifications/create.ts +71 -0
  292. package/src/lib/notifications/dispatcher.ts +490 -0
  293. package/src/lib/notifications/index.ts +7 -0
  294. package/src/lib/notifications/push-sender.ts +128 -0
  295. package/src/lib/notifications/push.ts +64 -0
  296. package/src/lib/notifications/triggers.ts +166 -0
  297. package/src/lib/utils/__tests__/crypto.test.ts +30 -0
  298. package/src/lib/utils/__tests__/export.test.ts +55 -0
  299. package/src/lib/utils/__tests__/nav-badge.test.ts +147 -0
  300. package/src/lib/utils/__tests__/staff-status.test.ts +50 -0
  301. package/src/lib/utils/attestations.ts +19 -0
  302. package/src/lib/utils/crypto.ts +18 -0
  303. package/src/lib/utils/csv-parser.ts +181 -0
  304. package/src/lib/utils/export.ts +45 -0
  305. package/src/lib/utils/nav-badge.ts +225 -0
  306. package/src/lib/utils/sound.ts +152 -0
  307. package/src/lib/utils/staff-status.ts +115 -0
  308. package/src/lib/utils.ts +6 -0
  309. package/src/lib/validators/__tests__/auth.test.ts +127 -0
  310. package/src/lib/validators/__tests__/data-management.test.ts +182 -0
  311. package/src/lib/validators/__tests__/devices-profile.test.ts +129 -0
  312. package/src/lib/validators/__tests__/devices.test.ts +98 -0
  313. package/src/lib/validators/__tests__/engineer-rounds.test.ts +170 -0
  314. package/src/lib/validators/__tests__/maintenance.test.ts +178 -0
  315. package/src/lib/validators/__tests__/signatures.test.ts +154 -0
  316. package/src/lib/validators/__tests__/system-settings.test.ts +119 -0
  317. package/src/lib/validators/__tests__/tickets.test.ts +176 -0
  318. package/src/lib/validators/__tests__/users.test.ts +92 -0
  319. package/src/lib/validators/auth.ts +32 -0
  320. package/src/lib/validators/data-management.ts +67 -0
  321. package/src/lib/validators/departments.ts +14 -0
  322. package/src/lib/validators/devices.ts +94 -0
  323. package/src/lib/validators/engineer-rounds.ts +70 -0
  324. package/src/lib/validators/hospitals.ts +17 -0
  325. package/src/lib/validators/locations.ts +15 -0
  326. package/src/lib/validators/maintenance.ts +211 -0
  327. package/src/lib/validators/organizations.ts +11 -0
  328. package/src/lib/validators/reports.ts +31 -0
  329. package/src/lib/validators/signatures.ts +49 -0
  330. package/src/lib/validators/system-settings.ts +55 -0
  331. package/src/lib/validators/tickets.ts +125 -0
  332. package/src/lib/validators/users.ts +43 -0
  333. package/src/middleware.ts +45 -0
  334. package/tailwind.config.ts +76 -0
  335. package/tsconfig.json +28 -0
@@ -0,0 +1,490 @@
1
+ 'use server';
2
+
3
+ import { db } from '@/lib/db';
4
+ import { notifications, users } from '@/lib/db/schema';
5
+ import { userRoleAssignments, userAccessScopes, roles as rolesTable } from '@/lib/db/schema';
6
+ import { eq, and, inArray } from 'drizzle-orm';
7
+ import { sendPushToUser, type PushNotificationPayload } from './push-sender';
8
+
9
+ // ── Types ────────────────────────────────────────────────────────────────────────
10
+
11
+ interface TicketNotificationData {
12
+ ticketId: string;
13
+ ticketNumber: string;
14
+ title: string;
15
+ organizationId: string;
16
+ hospitalId: string;
17
+ departmentId: string;
18
+ departmentName?: string;
19
+ deviceName?: string;
20
+ reportedImpact: string;
21
+ priorityCode: string;
22
+ reportedByUserId: string;
23
+ reportedByName?: string;
24
+ }
25
+
26
+ // ── Impact Labels ────────────────────────────────────────────────────────────────
27
+
28
+ const IMPACT_LABELS: Record<string, string> = {
29
+ patient_care_affected: '🚨 Patient care affected — URGENT',
30
+ device_not_usable: '⚠️ Device not usable',
31
+ device_usable: 'Device usable with issue',
32
+ };
33
+
34
+ const PRIORITY_LABELS: Record<string, string> = {
35
+ p1_critical: 'CRITICAL',
36
+ p2_high: 'HIGH',
37
+ p3_normal: 'NORMAL',
38
+ p4_low: 'LOW',
39
+ };
40
+
41
+ // ── Find Scoped Engineers ────────────────────────────────────────────────────────
42
+
43
+ type UserNotificationPreferences = {
44
+ pushEnabled?: boolean;
45
+ categories?: {
46
+ tickets?: boolean;
47
+ maintenance?: boolean;
48
+ devices?: boolean;
49
+ system?: boolean;
50
+ };
51
+ } | null;
52
+
53
+ /**
54
+ * Find all active biomedical engineers/technicians/managers scoped to the same
55
+ * organization and hospital as the ticket.
56
+ */
57
+ async function findScopedEngineers(
58
+ organizationId: string,
59
+ hospitalId: string,
60
+ ): Promise<Array<{ id: string; fullName: string; notificationPreferences: UserNotificationPreferences }>> {
61
+ const BME_ROLE_CODES = ['BIOMED_ENG', 'BIOMED_TECH', 'BIOMED_MGR'];
62
+
63
+ // Find role IDs for BME roles
64
+ const bmeRoles = await db.query.roles.findMany({
65
+ where: inArray(rolesTable.code, BME_ROLE_CODES),
66
+ columns: { id: true, code: true },
67
+ });
68
+
69
+ if (bmeRoles.length === 0) return [];
70
+ const bmeRoleIds = bmeRoles.map((r) => r.id);
71
+
72
+ // Find users with BME role assignments
73
+ const roleAssignments = await db.query.userRoleAssignments.findMany({
74
+ where: and(
75
+ inArray(userRoleAssignments.roleId, bmeRoleIds),
76
+ eq(userRoleAssignments.status, 'active'),
77
+ ),
78
+ columns: { userId: true },
79
+ });
80
+
81
+ const candidateUserIds = [...new Set(roleAssignments.map((ra) => ra.userId))];
82
+ if (candidateUserIds.length === 0) return [];
83
+
84
+ // Filter by access scope — must have access to the ticket's organization+hospital
85
+ const scopedUserIds: string[] = [];
86
+
87
+ for (const userId of candidateUserIds) {
88
+ const scopes = await db.query.userAccessScopes.findMany({
89
+ where: and(
90
+ eq(userAccessScopes.userId, userId),
91
+ eq(userAccessScopes.organizationId, organizationId),
92
+ eq(userAccessScopes.status, 'active'),
93
+ ),
94
+ });
95
+
96
+ // User has access if they have an org-level scope, or a scope matching the hospital
97
+ const hasAccess = scopes.some(
98
+ (s) => !s.hospitalId || s.hospitalId === hospitalId,
99
+ );
100
+
101
+ if (hasAccess) {
102
+ scopedUserIds.push(userId);
103
+ }
104
+ }
105
+
106
+ if (scopedUserIds.length === 0) return [];
107
+
108
+ // Get user details
109
+ const engineers = await db.query.users.findMany({
110
+ where: and(
111
+ inArray(users.id, scopedUserIds),
112
+ eq(users.accountStatus, 'active'),
113
+ ),
114
+ columns: {
115
+ id: true,
116
+ fullName: true,
117
+ notificationPreferences: true,
118
+ },
119
+ });
120
+
121
+ return engineers;
122
+ }
123
+
124
+ // ── Create In-App Notification ───────────────────────────────────────────────────
125
+
126
+ async function createInAppNotification(params: {
127
+ organizationId: string;
128
+ recipientUserId: string;
129
+ notificationType: string;
130
+ title: string;
131
+ body: string;
132
+ relatedEntityType?: string;
133
+ relatedEntityId?: string;
134
+ actionUrl?: string;
135
+ }) {
136
+ await db.insert(notifications).values({
137
+ organizationId: params.organizationId,
138
+ recipientUserId: params.recipientUserId,
139
+ notificationType: params.notificationType as any,
140
+ title: params.title,
141
+ body: params.body,
142
+ relatedEntityType: params.relatedEntityType || null,
143
+ relatedEntityId: params.relatedEntityId || null,
144
+ actionUrl: params.actionUrl || null,
145
+ });
146
+ }
147
+
148
+ // ── Notify Engineers on New Ticket ───────────────────────────────────────────────
149
+
150
+ /**
151
+ * When a staff member creates a ticket, notify all scoped biomedical engineers.
152
+ * Creates both in-app notifications and sends native push notifications.
153
+ */
154
+ export async function notifyEngineersOnNewTicket(
155
+ data: TicketNotificationData,
156
+ ): Promise<{ notifiedCount: number; pushCount: number }> {
157
+ try {
158
+ const engineers = await findScopedEngineers(data.organizationId, data.hospitalId);
159
+
160
+ // Exclude the reporter if they happen to be a BME user
161
+ const recipients = engineers.filter((eng) => eng.id !== data.reportedByUserId);
162
+
163
+ if (recipients.length === 0) {
164
+ return { notifiedCount: 0, pushCount: 0 };
165
+ }
166
+
167
+ const isCritical = data.priorityCode === 'p1_critical' || data.reportedImpact === 'patient_care_affected';
168
+ const impactLabel = IMPACT_LABELS[data.reportedImpact] || data.reportedImpact;
169
+
170
+ const locationContext = data.departmentName || 'Unknown department';
171
+ const deviceContext = data.deviceName || 'Unknown device';
172
+
173
+ // Notification content
174
+ const notifTitle = isCritical
175
+ ? `🚨 URGENT Ticket: ${data.ticketNumber}`
176
+ : `🔧 New Ticket: ${data.ticketNumber}`;
177
+
178
+ const notifBody = `${locationContext} — ${deviceContext}: ${data.title}. ${impactLabel}`;
179
+
180
+ const actionUrl = `/tickets/${data.ticketId}`;
181
+
182
+ let notifiedCount = 0;
183
+ let pushCount = 0;
184
+
185
+ await Promise.allSettled(
186
+ recipients.map(async (engineer) => {
187
+ // Check notification preferences
188
+ const prefs = engineer.notificationPreferences;
189
+ const ticketsEnabled = !prefs || prefs.categories?.tickets !== false;
190
+
191
+ if (!ticketsEnabled) return;
192
+
193
+ // 1. Create in-app notification
194
+ await createInAppNotification({
195
+ organizationId: data.organizationId,
196
+ recipientUserId: engineer.id,
197
+ notificationType: 'ticket_created',
198
+ title: notifTitle,
199
+ body: notifBody,
200
+ relatedEntityType: 'service_ticket',
201
+ relatedEntityId: data.ticketId,
202
+ actionUrl,
203
+ });
204
+ notifiedCount++;
205
+
206
+ // 2. Send native push notification
207
+ const pushEnabled = !prefs || prefs.pushEnabled !== false;
208
+ if (pushEnabled) {
209
+ const pushPayload: PushNotificationPayload = {
210
+ title: notifTitle,
211
+ body: notifBody,
212
+ tag: `ticket-${data.ticketNumber}`,
213
+ url: actionUrl,
214
+ requireInteraction: isCritical,
215
+ vibrate: isCritical ? [200, 100, 200, 100, 200] : [100, 50, 100],
216
+ actions: [
217
+ { action: 'view', title: 'View Ticket' },
218
+ ],
219
+ data: {
220
+ ticketId: data.ticketId,
221
+ ticketNumber: data.ticketNumber,
222
+ priority: data.priorityCode,
223
+ impact: data.reportedImpact,
224
+ type: 'new_ticket',
225
+ },
226
+ };
227
+
228
+ const deviceCount = await sendPushToUser(engineer.id, pushPayload);
229
+ pushCount += deviceCount;
230
+ }
231
+ }),
232
+ );
233
+
234
+ console.log(`[Dispatcher] Notified ${notifiedCount} engineer(s), ${pushCount} device(s) pushed for ticket ${data.ticketNumber}`);
235
+ return { notifiedCount, pushCount };
236
+ } catch (error) {
237
+ console.error('[Dispatcher] Error notifying engineers:', error);
238
+ return { notifiedCount: 0, pushCount: 0 };
239
+ }
240
+ }
241
+
242
+ // ── Notify Reporter on Ticket Accept ─────────────────────────────────────────────
243
+
244
+ /**
245
+ * When an engineer accepts a ticket, notify the original reporter.
246
+ */
247
+ export async function notifyReporterOnAccept(params: {
248
+ ticketId: string;
249
+ ticketNumber: string;
250
+ title: string;
251
+ organizationId: string;
252
+ reporterUserId: string;
253
+ engineerName: string;
254
+ engineerUserId: string;
255
+ }): Promise<void> {
256
+ try {
257
+ const notifTitle = '✅ Your ticket has been accepted';
258
+ const notifBody = `Engineer ${params.engineerName} is now working on your reported issue (${params.ticketNumber}: ${params.title})`;
259
+ const actionUrl = `/tickets/${params.ticketId}`;
260
+
261
+ // 1. Create in-app notification for reporter
262
+ await createInAppNotification({
263
+ organizationId: params.organizationId,
264
+ recipientUserId: params.reporterUserId,
265
+ notificationType: 'ticket_updated',
266
+ title: notifTitle,
267
+ body: notifBody,
268
+ relatedEntityType: 'service_ticket',
269
+ relatedEntityId: params.ticketId,
270
+ actionUrl,
271
+ });
272
+
273
+ // 2. Send native push to reporter
274
+ const reporter = await db.query.users.findFirst({
275
+ where: eq(users.id, params.reporterUserId),
276
+ columns: { notificationPreferences: true },
277
+ });
278
+
279
+ const prefs = reporter?.notificationPreferences;
280
+ const pushEnabled = !prefs || prefs.pushEnabled !== false;
281
+ const ticketsEnabled = !prefs || prefs.categories?.tickets !== false;
282
+
283
+ if (pushEnabled && ticketsEnabled) {
284
+ await sendPushToUser(params.reporterUserId, {
285
+ title: notifTitle,
286
+ body: notifBody,
287
+ tag: `ticket-accepted-${params.ticketNumber}`,
288
+ url: actionUrl,
289
+ vibrate: [100, 50, 100],
290
+ data: {
291
+ ticketId: params.ticketId,
292
+ type: 'ticket_accepted',
293
+ },
294
+ });
295
+ }
296
+
297
+ console.log(`[Dispatcher] Notified reporter of ticket ${params.ticketNumber} acceptance by ${params.engineerName}`);
298
+ } catch (error) {
299
+ console.error('[Dispatcher] Error notifying reporter on accept:', error);
300
+ }
301
+ }
302
+
303
+ // ── Notify Reporter on Ticket Status Update ──────────────────────────────────────
304
+
305
+ /**
306
+ * When ticket status changes (resolved, in progress, waiting, etc.),
307
+ * notify the reporter with a plain-language update.
308
+ */
309
+ export async function notifyReporterOnStatusChange(params: {
310
+ ticketId: string;
311
+ ticketNumber: string;
312
+ title: string;
313
+ organizationId: string;
314
+ reporterUserId: string;
315
+ newStatus: string;
316
+ actorName: string;
317
+ }): Promise<void> {
318
+ try {
319
+ const STATUS_MESSAGES: Record<string, string> = {
320
+ in_progress: 'is being investigated',
321
+ waiting_requester: 'needs your response — please reply',
322
+ waiting_parts_vendor: 'is waiting for parts to arrive',
323
+ resolved: 'has been resolved',
324
+ closed: 'has been closed',
325
+ };
326
+
327
+ const statusMsg = STATUS_MESSAGES[params.newStatus];
328
+ if (!statusMsg) return; // Don't notify for internal status transitions
329
+
330
+ const notifTitle = params.newStatus === 'waiting_requester'
331
+ ? '💬 Response needed on your ticket'
332
+ : `📋 Ticket update: ${params.ticketNumber}`;
333
+ const notifBody = `Your ticket "${params.title}" ${statusMsg}.`;
334
+
335
+ await createInAppNotification({
336
+ organizationId: params.organizationId,
337
+ recipientUserId: params.reporterUserId,
338
+ notificationType: 'ticket_updated',
339
+ title: notifTitle,
340
+ body: notifBody,
341
+ relatedEntityType: 'service_ticket',
342
+ relatedEntityId: params.ticketId,
343
+ actionUrl: `/tickets/${params.ticketId}`,
344
+ });
345
+
346
+ // Push for important statuses
347
+ const pushStatuses = ['waiting_requester', 'resolved'];
348
+ if (pushStatuses.includes(params.newStatus)) {
349
+ const reporter = await db.query.users.findFirst({
350
+ where: eq(users.id, params.reporterUserId),
351
+ columns: { notificationPreferences: true },
352
+ });
353
+ const prefs = reporter?.notificationPreferences;
354
+ if (!prefs || (prefs.pushEnabled !== false && prefs.categories?.tickets !== false)) {
355
+ await sendPushToUser(params.reporterUserId, {
356
+ title: notifTitle,
357
+ body: notifBody,
358
+ tag: `ticket-status-${params.ticketNumber}`,
359
+ url: `/tickets/${params.ticketId}`,
360
+ requireInteraction: params.newStatus === 'waiting_requester',
361
+ vibrate: [100, 50, 100],
362
+ });
363
+ }
364
+ }
365
+ } catch (error) {
366
+ console.error('[Dispatcher] Error notifying reporter on status change:', error);
367
+ }
368
+ }
369
+
370
+ // ── Notify on New Comment ────────────────────────────────────────────────────────
371
+
372
+ /**
373
+ * When a comment is added to a ticket, notify the other party.
374
+ * If the commenter is the reporter → notify the assigned engineer.
375
+ * If the commenter is the engineer → notify the reporter.
376
+ */
377
+ export async function notifyOnNewComment(params: {
378
+ ticketId: string;
379
+ ticketNumber: string;
380
+ title: string;
381
+ organizationId: string;
382
+ commentAuthorUserId: string;
383
+ commentAuthorName: string;
384
+ reporterUserId: string;
385
+ assignedEngineerUserId: string | null;
386
+ commentPreview: string;
387
+ }): Promise<void> {
388
+ try {
389
+ const isReporterCommenting = params.commentAuthorUserId === params.reporterUserId;
390
+ const recipientUserId = isReporterCommenting
391
+ ? params.assignedEngineerUserId
392
+ : params.reporterUserId;
393
+
394
+ if (!recipientUserId) return; // No one to notify
395
+
396
+ const notifTitle = `💬 New message on ticket ${params.ticketNumber}`;
397
+ const preview = params.commentPreview.length > 80
398
+ ? params.commentPreview.substring(0, 80) + '…'
399
+ : params.commentPreview;
400
+ const notifBody = `${params.commentAuthorName}: ${preview}`;
401
+
402
+ await createInAppNotification({
403
+ organizationId: params.organizationId,
404
+ recipientUserId,
405
+ notificationType: 'ticket_updated',
406
+ title: notifTitle,
407
+ body: notifBody,
408
+ relatedEntityType: 'service_ticket',
409
+ relatedEntityId: params.ticketId,
410
+ actionUrl: `/tickets/${params.ticketId}`,
411
+ });
412
+
413
+ // Send push notification for new messages
414
+ const recipient = await db.query.users.findFirst({
415
+ where: eq(users.id, recipientUserId),
416
+ columns: { notificationPreferences: true },
417
+ });
418
+ const prefs = recipient?.notificationPreferences;
419
+ if (!prefs || (prefs.pushEnabled !== false && prefs.categories?.tickets !== false)) {
420
+ await sendPushToUser(recipientUserId, {
421
+ title: notifTitle,
422
+ body: notifBody,
423
+ tag: `ticket-comment-${params.ticketNumber}`,
424
+ url: `/tickets/${params.ticketId}`,
425
+ vibrate: [100, 50, 100],
426
+ data: {
427
+ ticketId: params.ticketId,
428
+ type: 'ticket_comment',
429
+ },
430
+ });
431
+ }
432
+
433
+ console.log(`[Dispatcher] Notified ${isReporterCommenting ? 'engineer' : 'reporter'} of new comment on ${params.ticketNumber}`);
434
+ } catch (error) {
435
+ console.error('[Dispatcher] Error notifying on new comment:', error);
436
+ }
437
+ }
438
+
439
+ // ── Notify Assigned Engineer ─────────────────────────────────────────────────────
440
+
441
+ /**
442
+ * When a ticket is assigned to an engineer, notify them.
443
+ */
444
+ export async function notifyEngineerOnAssign(params: {
445
+ ticketId: string;
446
+ ticketNumber: string;
447
+ title: string;
448
+ organizationId: string;
449
+ assignedEngineerUserId: string;
450
+ assignerName: string;
451
+ }): Promise<void> {
452
+ try {
453
+ const notifTitle = `🔧 Ticket assigned to you: ${params.ticketNumber}`;
454
+ const notifBody = `${params.assignerName} assigned you to ticket "${params.title}". Please review and accept.`;
455
+
456
+ await createInAppNotification({
457
+ organizationId: params.organizationId,
458
+ recipientUserId: params.assignedEngineerUserId,
459
+ notificationType: 'ticket_updated',
460
+ title: notifTitle,
461
+ body: notifBody,
462
+ relatedEntityType: 'service_ticket',
463
+ relatedEntityId: params.ticketId,
464
+ actionUrl: `/tickets/${params.ticketId}`,
465
+ });
466
+
467
+ const engineer = await db.query.users.findFirst({
468
+ where: eq(users.id, params.assignedEngineerUserId),
469
+ columns: { notificationPreferences: true },
470
+ });
471
+ const prefs = engineer?.notificationPreferences;
472
+ if (!prefs || (prefs.pushEnabled !== false && prefs.categories?.tickets !== false)) {
473
+ await sendPushToUser(params.assignedEngineerUserId, {
474
+ title: notifTitle,
475
+ body: notifBody,
476
+ tag: `ticket-assigned-${params.ticketNumber}`,
477
+ url: `/tickets/${params.ticketId}`,
478
+ vibrate: [100, 50, 100],
479
+ data: {
480
+ ticketId: params.ticketId,
481
+ type: 'ticket_assigned',
482
+ },
483
+ });
484
+ }
485
+
486
+ console.log(`[Dispatcher] Notified engineer of assignment for ticket ${params.ticketNumber}`);
487
+ } catch (error) {
488
+ console.error('[Dispatcher] Error notifying engineer on assign:', error);
489
+ }
490
+ }
@@ -0,0 +1,7 @@
1
+ export { sendPushToUser, sendPushToUsers } from './push-sender';
2
+ export type { PushNotificationPayload } from './push-sender';
3
+ export {
4
+ notifyEngineersOnNewTicket,
5
+ notifyReporterOnAccept,
6
+ notifyReporterOnStatusChange,
7
+ } from './dispatcher';
@@ -0,0 +1,128 @@
1
+ 'use server';
2
+
3
+ import webpush from 'web-push';
4
+ import { db } from '@/lib/db';
5
+ import { pushSubscriptions } from '@/lib/db/schema';
6
+ import { eq } from 'drizzle-orm';
7
+
8
+ // ── Configure VAPID ─────────────────────────────────────────────────────────────
9
+
10
+ const VAPID_PUBLIC_KEY = process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY || '';
11
+ const VAPID_PRIVATE_KEY = process.env.VAPID_PRIVATE_KEY || '';
12
+ const VAPID_SUBJECT = process.env.VAPID_SUBJECT || 'mailto:admin@bemms.local';
13
+
14
+ if (VAPID_PUBLIC_KEY && VAPID_PRIVATE_KEY) {
15
+ webpush.setVapidDetails(VAPID_SUBJECT, VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY);
16
+ }
17
+
18
+ // ── Push Payload Types ──────────────────────────────────────────────────────────
19
+
20
+ export interface PushNotificationPayload {
21
+ title: string;
22
+ body: string;
23
+ /** Tag for notification grouping/replacement */
24
+ tag?: string;
25
+ /** URL to navigate to when notification is clicked */
26
+ url?: string;
27
+ /** Native notification action buttons */
28
+ actions?: Array<{ action: string; title: string }>;
29
+ /** If true, notification stays until user dismisses it */
30
+ requireInteraction?: boolean;
31
+ /** Vibration pattern in ms [vibrate, pause, vibrate, ...] */
32
+ vibrate?: number[];
33
+ /** Extra data passed through to service worker */
34
+ data?: Record<string, unknown>;
35
+ }
36
+
37
+ // ── Send Push to a Single User ──────────────────────────────────────────────────
38
+
39
+ /**
40
+ * Send a native push notification to all registered devices for a given user.
41
+ * Automatically removes expired/invalid subscriptions.
42
+ *
43
+ * @returns Number of devices successfully notified
44
+ */
45
+ export async function sendPushToUser(
46
+ userId: string,
47
+ payload: PushNotificationPayload,
48
+ ): Promise<number> {
49
+ if (!VAPID_PUBLIC_KEY || !VAPID_PRIVATE_KEY) {
50
+ console.warn('[Push] VAPID keys not configured — skipping push notification');
51
+ return 0;
52
+ }
53
+
54
+ // Get all push subscriptions for this user
55
+ const subscriptions = await db.query.pushSubscriptions.findMany({
56
+ where: eq(pushSubscriptions.userId, userId),
57
+ });
58
+
59
+ if (subscriptions.length === 0) return 0;
60
+
61
+ const jsonPayload = JSON.stringify(payload);
62
+ let successCount = 0;
63
+ const expiredIds: string[] = [];
64
+
65
+ await Promise.allSettled(
66
+ subscriptions.map(async (sub) => {
67
+ try {
68
+ await webpush.sendNotification(
69
+ {
70
+ endpoint: sub.endpoint,
71
+ keys: {
72
+ p256dh: sub.p256dh,
73
+ auth: sub.auth,
74
+ },
75
+ },
76
+ jsonPayload,
77
+ {
78
+ TTL: 60 * 60 * 4, // 4 hours
79
+ urgency: payload.requireInteraction ? 'high' : 'normal',
80
+ },
81
+ );
82
+ successCount++;
83
+ } catch (error: unknown) {
84
+ const pushError = error as { statusCode?: number; message?: string };
85
+ // 410 Gone or 404 = subscription expired/invalid
86
+ if (pushError.statusCode === 410 || pushError.statusCode === 404) {
87
+ expiredIds.push(sub.id);
88
+ } else {
89
+ console.error(`[Push] Failed to send to ${sub.endpoint.substring(0, 50)}...`, pushError.statusCode || pushError.message);
90
+ }
91
+ }
92
+ }),
93
+ );
94
+
95
+ // Clean up expired subscriptions
96
+ if (expiredIds.length > 0) {
97
+ await Promise.allSettled(
98
+ expiredIds.map((id) =>
99
+ db.delete(pushSubscriptions).where(eq(pushSubscriptions.id, id)),
100
+ ),
101
+ );
102
+ console.log(`[Push] Cleaned up ${expiredIds.length} expired subscription(s)`);
103
+ }
104
+
105
+ return successCount;
106
+ }
107
+
108
+ // ── Send Push to Multiple Users ─────────────────────────────────────────────────
109
+
110
+ /**
111
+ * Send a native push notification to multiple users.
112
+ * @returns Total number of devices successfully notified
113
+ */
114
+ export async function sendPushToUsers(
115
+ userIds: string[],
116
+ payload: PushNotificationPayload,
117
+ ): Promise<number> {
118
+ if (userIds.length === 0) return 0;
119
+
120
+ const results = await Promise.allSettled(
121
+ userIds.map((userId) => sendPushToUser(userId, payload)),
122
+ );
123
+
124
+ return results.reduce((total, result) => {
125
+ if (result.status === 'fulfilled') return total + result.value;
126
+ return total;
127
+ }, 0);
128
+ }
@@ -0,0 +1,64 @@
1
+ import webPush from 'web-push';
2
+ import { db } from '@/lib/db';
3
+ import { pushSubscriptions } from '@/lib/db/schema';
4
+ import { eq } from 'drizzle-orm';
5
+
6
+ // Configure VAPID
7
+ const vapidPublicKey = process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY!;
8
+ const vapidPrivateKey = process.env.VAPID_PRIVATE_KEY!;
9
+ const vapidSubject = process.env.VAPID_SUBJECT || 'mailto:admin@bemms.app';
10
+
11
+ if (vapidPublicKey && vapidPrivateKey) {
12
+ webPush.setVapidDetails(vapidSubject, vapidPublicKey, vapidPrivateKey);
13
+ }
14
+
15
+ export interface PushPayload {
16
+ title: string;
17
+ body: string;
18
+ url?: string;
19
+ tag?: string;
20
+ requireInteraction?: boolean;
21
+ actions?: { action: string; title: string }[];
22
+ }
23
+
24
+ export async function sendPushToUser(userId: string, payload: PushPayload) {
25
+ if (!vapidPublicKey || !vapidPrivateKey) {
26
+ console.warn('[Push] VAPID keys not configured, skipping push notification');
27
+ return;
28
+ }
29
+
30
+ const subscriptions = await db.query.pushSubscriptions.findMany({
31
+ where: eq(pushSubscriptions.userId, userId),
32
+ });
33
+
34
+ if (subscriptions.length === 0) return;
35
+
36
+ const results = await Promise.allSettled(
37
+ subscriptions.map(async (sub) => {
38
+ try {
39
+ await webPush.sendNotification(
40
+ {
41
+ endpoint: sub.endpoint,
42
+ keys: {
43
+ p256dh: sub.p256dh,
44
+ auth: sub.auth,
45
+ },
46
+ },
47
+ JSON.stringify(payload)
48
+ );
49
+ } catch (error: unknown) {
50
+ const pushError = error as { statusCode?: number; message?: string };
51
+ // 404 or 410 = subscription expired, remove it
52
+ if (pushError.statusCode === 404 || pushError.statusCode === 410) {
53
+ await db.delete(pushSubscriptions).where(eq(pushSubscriptions.id, sub.id));
54
+ console.log(`[Push] Removed expired subscription ${sub.id}`);
55
+ } else {
56
+ console.error(`[Push] Failed to send to ${sub.id}:`, pushError.message);
57
+ }
58
+ }
59
+ })
60
+ );
61
+
62
+ return results;
63
+ }
64
+