@omersx/bemms 0.0.0-stage → 1.0.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,55 @@
1
+ import { auth } from '@/lib/auth';
2
+ import { redirect } from 'next/navigation';
3
+ import { hasRole } from '@/lib/auth/rbac';
4
+ import { listTickets } from "@/lib/actions/tickets";
5
+ import { getMyTickets } from "@/lib/actions/dashboard";
6
+ import { PageHeader } from "@/components/shared/page-header";
7
+ import { TicketCardsView } from "@/components/tickets/ticket-cards-view";
8
+ import { StaffTicketsView } from "@/components/tickets/staff-tickets-view";
9
+ import { TicketListAutoRefresh } from "@/components/tickets/ticket-list-auto-refresh";
10
+ import { TicketsNav } from "@/components/tickets/tickets-nav";
11
+ import { Button } from "@/components/ui/button";
12
+ import { Plus, Filter } from "lucide-react";
13
+ import Link from "next/link";
14
+
15
+ export const dynamic = 'force-dynamic';
16
+
17
+ export default async function TicketsPage() {
18
+ const session = await auth();
19
+ if (!session?.user) redirect('/login');
20
+
21
+ const user = session.user as { id: string; roles: string[] };
22
+ const roles = user.roles || [];
23
+ const isBiomed = hasRole(roles, 'BIOMED_ENG', 'BIOMED_TECH', 'BIOMED_MGR');
24
+ const isAdmin = hasRole(roles, 'SYS_ADMIN', 'ORG_ADMIN', 'HOSP_ADMIN');
25
+ const isStaffOnly = hasRole(roles, 'STAFF', 'DEPT_MGR') && !isBiomed && !isAdmin;
26
+
27
+ if (isStaffOnly) {
28
+ // Staff view: show only their own tickets with plain-language status, search, and filters
29
+ const myTickets = await getMyTickets(50);
30
+ return (
31
+ <>
32
+ <TicketListAutoRefresh />
33
+ <StaffTicketsView tickets={myTickets} />
34
+ </>
35
+ );
36
+ }
37
+
38
+ // Full view for BME/admin
39
+ const result = await listTickets();
40
+ const tickets = (result?.success && Array.isArray(result.data)) ? result.data : [];
41
+
42
+ return (
43
+ <div className="space-y-6">
44
+ <TicketListAutoRefresh />
45
+ <PageHeader
46
+ title="Helpdesk Tickets"
47
+ description="Track device problems, service requests, and maintenance progress"
48
+ />
49
+
50
+ <TicketsNav />
51
+
52
+ <TicketCardsView tickets={tickets as any} />
53
+ </div>
54
+ );
55
+ }
@@ -0,0 +1,161 @@
1
+ import { getTriageQueue } from "@/lib/actions/tickets"
2
+ import { PageHeader } from "@/components/shared/page-header"
3
+ import { DataTable } from "@/components/shared/data-table"
4
+ import { TicketsNav } from "@/components/tickets/tickets-nav"
5
+ import { TicketStatusBadge, TicketPriorityBadge, TicketImpactBadge } from "@/components/tickets/ticket-badges"
6
+ import { WaitingForEngineerBanner } from "@/components/tickets/ticket-dispatch-status"
7
+ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
8
+ import { TicketListAutoRefresh } from "@/components/tickets/ticket-list-auto-refresh"
9
+ import { AlertTriangle, Clock, Inbox, CheckCircle2, Timer } from "lucide-react"
10
+ import Link from "next/link"
11
+
12
+ export default async function TriageQueuePage() {
13
+ const result = await getTriageQueue()
14
+ const tickets = (result?.success && Array.isArray(result.data)) ? result.data : []
15
+
16
+ // Queue metrics
17
+ const newCount = tickets.filter((t: any) => t.statusCode === 'new').length
18
+ const criticalCount = tickets.filter((t: any) =>
19
+ t.reportedImpact === 'patient_care_affected' && t.statusCode === 'new'
20
+ ).length
21
+ const unacceptedCount = tickets.filter((t: any) =>
22
+ !t.assignedEngineerUserId && t.statusCode === 'new'
23
+ ).length
24
+
25
+ // Calculate average wait time for new tickets
26
+ const newTickets = tickets.filter((t: any) => t.statusCode === 'new')
27
+ const avgWaitHours = newTickets.length > 0
28
+ ? Math.round(newTickets.reduce((sum: number, t: any) => {
29
+ return sum + (Date.now() - new Date(t.reportedAt).getTime()) / (1000 * 60 * 60)
30
+ }, 0) / newTickets.length)
31
+ : 0
32
+
33
+ const columns = [
34
+ { key: "priority", header: "Priority", render: (item: any) => (
35
+ <TicketPriorityBadge priority={item.priorityCode} />
36
+ )},
37
+ { key: "ticketNumber", header: "Ticket #", render: (item: any) => (
38
+ <Link href={`/tickets/${item.id}`} className="text-primary hover:underline font-mono text-sm">
39
+ {item.ticketNumber}
40
+ </Link>
41
+ )},
42
+ { key: "title", header: "Issue", render: (item: any) => (
43
+ <div className="space-y-1">
44
+ <Link href={`/tickets/${item.id}`} className="font-medium hover:underline line-clamp-1">
45
+ {item.title}
46
+ </Link>
47
+ <span className="text-xs text-muted-foreground">{item.device?.name || "No device"}</span>
48
+ {/* Show waiting status for unaccepted tickets */}
49
+ {item.statusCode === 'new' && !item.assignedEngineerUserId && (
50
+ <div className="flex items-center gap-1 text-xs text-amber-600 dark:text-amber-400 font-medium">
51
+ <Clock className="w-3 h-3 animate-pulse" />
52
+ <span>Waiting for engineer</span>
53
+ </div>
54
+ )}
55
+ </div>
56
+ )},
57
+ { key: "impact", header: "Reported Impact", render: (item: any) => (
58
+ <TicketImpactBadge impact={item.reportedImpact} />
59
+ )},
60
+ { key: "location", header: "Location", render: (item: any) => (
61
+ <span className="text-sm">{item.hospital?.name} / {item.department?.name}</span>
62
+ )},
63
+ { key: "status", header: "Status", render: (item: any) => (
64
+ <TicketStatusBadge status={item.statusCode} />
65
+ )},
66
+ { key: "age", header: "Age", render: (item: any) => {
67
+ const hours = Math.floor((Date.now() - new Date(item.reportedAt).getTime()) / (1000 * 60 * 60))
68
+ return (
69
+ <span className={`text-sm flex items-center gap-1 ${hours > 24 ? 'text-red-600 dark:text-red-400 font-medium' : hours > 4 ? 'text-amber-600 dark:text-amber-400 font-medium' : 'text-muted-foreground'}`}>
70
+ <Clock className="w-3 h-3" />
71
+ {hours < 1 ? '< 1h' : `${hours}h`}
72
+ </span>
73
+ )
74
+ }},
75
+ ]
76
+
77
+ return (
78
+ <div className="space-y-6">
79
+ <TicketListAutoRefresh />
80
+ <PageHeader
81
+ title="Triage Queue"
82
+ description="Review, classify, and assign incoming service requests"
83
+ />
84
+
85
+ <TicketsNav />
86
+
87
+ {/* Queue Metrics */}
88
+ <div className="grid gap-3 grid-cols-2 lg:grid-cols-4">
89
+ <Card>
90
+ <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
91
+ <CardTitle className="text-sm font-medium">Awaiting Triage</CardTitle>
92
+ <Inbox className="h-4 w-4 text-blue-500" />
93
+ </CardHeader>
94
+ <CardContent>
95
+ <div className="text-2xl font-bold">{newCount}</div>
96
+ <p className="text-xs text-muted-foreground mt-1">New tickets to review</p>
97
+ </CardContent>
98
+ </Card>
99
+
100
+ <Card className={criticalCount > 0 ? 'border-red-300 dark:border-red-800' : ''}>
101
+ <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
102
+ <CardTitle className="text-sm font-medium">Critical</CardTitle>
103
+ <AlertTriangle className={`h-4 w-4 ${criticalCount > 0 ? 'text-red-600 animate-pulse' : 'text-muted-foreground'}`} />
104
+ </CardHeader>
105
+ <CardContent>
106
+ <div className={`text-2xl font-bold ${criticalCount > 0 ? 'text-red-600' : ''}`}>
107
+ {criticalCount}
108
+ </div>
109
+ <p className="text-xs text-muted-foreground mt-1">Patient care affected</p>
110
+ </CardContent>
111
+ </Card>
112
+
113
+ <Card>
114
+ <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
115
+ <CardTitle className="text-sm font-medium">Unaccepted</CardTitle>
116
+ <Timer className="h-4 w-4 text-amber-500" />
117
+ </CardHeader>
118
+ <CardContent>
119
+ <div className="text-2xl font-bold">{unacceptedCount}</div>
120
+ <p className="text-xs text-muted-foreground mt-1">No engineer assigned yet</p>
121
+ </CardContent>
122
+ </Card>
123
+
124
+ <Card>
125
+ <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
126
+ <CardTitle className="text-sm font-medium">Avg Wait</CardTitle>
127
+ <Clock className="h-4 w-4 text-muted-foreground" />
128
+ </CardHeader>
129
+ <CardContent>
130
+ <div className={`text-2xl font-bold ${avgWaitHours > 24 ? 'text-red-600' : avgWaitHours > 4 ? 'text-amber-600' : ''}`}>
131
+ {avgWaitHours}h
132
+ </div>
133
+ <p className="text-xs text-muted-foreground mt-1">Average queue time</p>
134
+ </CardContent>
135
+ </Card>
136
+ </div>
137
+
138
+ {/* Critical Alert */}
139
+ {criticalCount > 0 && (
140
+ <div className="bg-red-50 dark:bg-red-950/30 border border-red-300 dark:border-red-800 rounded-lg p-4 flex items-start gap-3">
141
+ <AlertTriangle className="w-5 h-5 text-red-600 mt-0.5 shrink-0 animate-pulse" />
142
+ <div>
143
+ <h3 className="font-semibold text-red-800 dark:text-red-200">
144
+ {criticalCount} Critical Ticket{criticalCount > 1 ? 's' : ''} Awaiting Triage
145
+ </h3>
146
+ <p className="text-sm text-red-700 dark:text-red-300">
147
+ Tickets marked as affecting patient care require immediate attention. Please review and accept now.
148
+ </p>
149
+ </div>
150
+ </div>
151
+ )}
152
+
153
+ <DataTable
154
+ data={tickets}
155
+ columns={columns}
156
+ searchKey="title"
157
+ searchPlaceholder="Filter queue..."
158
+ />
159
+ </div>
160
+ )
161
+ }
@@ -0,0 +1,316 @@
1
+ import { NextRequest, NextResponse } from 'next/server';
2
+ import { requireAuth, requireRole } from '@/lib/auth/rbac';
3
+ import { db } from '@/lib/db';
4
+ import { generateCSV } from '@/lib/utils/export';
5
+ import * as XLSX from 'xlsx';
6
+
7
+ export async function GET(req: NextRequest) {
8
+ try {
9
+ const session = await requireAuth();
10
+ await requireRole('SYS_ADMIN', 'ORG_ADMIN', 'BIOMED_MGR', 'BIOMED_ENG');
11
+
12
+ const searchParams = req.nextUrl.searchParams;
13
+ const entityParam = searchParams.get('entities') || 'devices';
14
+ const format = (searchParams.get('format') || 'csv').toLowerCase();
15
+
16
+ const selectedEntities = entityParam.split(',').map((e) => e.trim());
17
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
18
+
19
+ if (format === 'json') {
20
+ const exportPayload: Record<string, any> = {
21
+ metadata: {
22
+ system: 'BEMMS Medical Equipment Management System',
23
+ exportedAt: new Date().toISOString(),
24
+ exportedBy: session.email,
25
+ entities: selectedEntities,
26
+ },
27
+ data: {},
28
+ };
29
+
30
+ if (selectedEntities.includes('devices')) {
31
+ exportPayload.data.devices = await db.query.devices.findMany({
32
+ with: {
33
+ deviceCategory: true,
34
+ manufacturer: true,
35
+ department: true,
36
+ },
37
+ });
38
+ }
39
+
40
+ if (selectedEntities.includes('departments')) {
41
+ exportPayload.data.departments = await db.query.departments.findMany();
42
+ }
43
+
44
+ if (selectedEntities.includes('catalogs')) {
45
+ exportPayload.data.categories = await db.query.deviceCategories.findMany();
46
+ exportPayload.data.manufacturers = await db.query.manufacturers.findMany();
47
+ }
48
+
49
+ if (selectedEntities.includes('tickets')) {
50
+ exportPayload.data.tickets = await db.query.serviceTickets.findMany({
51
+ with: { device: true },
52
+ });
53
+ }
54
+
55
+ if (selectedEntities.includes('audit_logs')) {
56
+ exportPayload.data.auditLogs = await db.query.auditLogs.findMany({
57
+ limit: 1000,
58
+ });
59
+ }
60
+
61
+ if (selectedEntities.includes('rounds')) {
62
+ exportPayload.data.engineerRounds = await db.query.engineerRounds.findMany({
63
+ with: {
64
+ engineer: { columns: { id: true, fullName: true, email: true } },
65
+ hospital: { columns: { id: true, name: true } },
66
+ departmentVisits: {
67
+ with: {
68
+ department: { columns: { id: true, name: true, code: true } },
69
+ notes: true,
70
+ },
71
+ },
72
+ },
73
+ });
74
+ }
75
+
76
+ return new NextResponse(JSON.stringify(exportPayload, null, 2), {
77
+ headers: {
78
+ 'Content-Type': 'application/json; charset=utf-8',
79
+ 'Content-Disposition': `attachment; filename="bemms_export_${timestamp}.json"`,
80
+ },
81
+ });
82
+ }
83
+
84
+ if (format === 'xlsx') {
85
+ const wb = XLSX.utils.book_new();
86
+
87
+ if (selectedEntities.includes('devices')) {
88
+ const rawDevices = await db.query.devices.findMany({
89
+ with: { deviceCategory: true, manufacturer: true, department: true },
90
+ });
91
+ const formatted = rawDevices.map((d) => ({
92
+ 'Asset Number': d.assetNumber,
93
+ 'Device Name': d.name,
94
+ 'Model': d.modelNameFree || '',
95
+ 'Serial Number': d.serialNumber || '',
96
+ 'Category': d.deviceCategory?.name || '',
97
+ 'Manufacturer': d.manufacturer?.name || '',
98
+ 'Department': d.department?.name || '',
99
+ 'Location': d.exactLocationDescription || '',
100
+ 'Status': d.currentStatusCode,
101
+ 'Criticality': d.criticalityLevel || 'medium',
102
+ 'Risk Class': d.riskClassification || '',
103
+ }));
104
+ const ws = XLSX.utils.json_to_sheet(formatted);
105
+ XLSX.utils.book_append_sheet(wb, ws, 'Devices');
106
+ }
107
+
108
+ if (selectedEntities.includes('departments')) {
109
+ const rawDeps = await db.query.departments.findMany({
110
+ with: { hospital: true },
111
+ });
112
+ const formatted = rawDeps.map((d) => ({
113
+ 'Department Name': d.name,
114
+ 'Code': d.code,
115
+ 'Department Type': d.departmentType,
116
+ 'Hospital Name': d.hospital?.name || '',
117
+ 'Status': d.status || 'active',
118
+ 'Created At': d.createdAt?.toISOString() || '',
119
+ }));
120
+ const ws = XLSX.utils.json_to_sheet(formatted);
121
+ XLSX.utils.book_append_sheet(wb, ws, 'Departments');
122
+ }
123
+
124
+ if (selectedEntities.includes('rounds')) {
125
+ const rawRounds = await db.query.engineerRounds.findMany({
126
+ with: {
127
+ engineer: true,
128
+ hospital: true,
129
+ departmentVisits: {
130
+ with: {
131
+ notes: true,
132
+ },
133
+ },
134
+ },
135
+ });
136
+
137
+ const formatted = rawRounds.map((r) => ({
138
+ 'Round Date': r.roundDate,
139
+ 'Shift': r.shiftType,
140
+ 'Engineer': r.engineer?.fullName || '',
141
+ 'Hospital': r.hospital?.name || '',
142
+ 'Status': r.status,
143
+ 'Started At': r.startedAt?.toISOString() || '',
144
+ 'Completed At': r.completedAt?.toISOString() || '',
145
+ 'Total Departments': r.totalDepartments,
146
+ 'Completed Departments': r.completedDepartments,
147
+ 'Issues Reported': r.totalIssuesReported,
148
+ 'Total Notes': r.totalNotes,
149
+ }));
150
+ const ws = XLSX.utils.json_to_sheet(formatted);
151
+ XLSX.utils.book_append_sheet(wb, ws, 'Daily Rounds');
152
+
153
+ const visitRows: any[] = [];
154
+ for (const r of rawRounds) {
155
+ for (const v of r.departmentVisits || []) {
156
+ const notesText = (v.notes || []).map((n: any) => n.noteText).join(' | ');
157
+ visitRows.push({
158
+ 'Round Date': r.roundDate,
159
+ 'Engineer': r.engineer?.fullName || '',
160
+ 'Department': v.departmentName,
161
+ 'Sequence': v.sequence,
162
+ 'Status': v.status,
163
+ 'Started At': v.startedAt?.toISOString() || '',
164
+ 'Completed At': v.completedAt?.toISOString() || '',
165
+ 'Notes': notesText,
166
+ });
167
+ }
168
+ }
169
+ if (visitRows.length > 0) {
170
+ const wsVisits = XLSX.utils.json_to_sheet(visitRows);
171
+ XLSX.utils.book_append_sheet(wb, wsVisits, 'Department Visits');
172
+ }
173
+ }
174
+
175
+ const buf = XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' });
176
+ return new NextResponse(buf, {
177
+ headers: {
178
+ 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
179
+ 'Content-Disposition': `attachment; filename="bemms_export_${timestamp}.xlsx"`,
180
+ },
181
+ });
182
+ }
183
+
184
+ if (format === 'csv') {
185
+ if (selectedEntities.includes('departments') && !selectedEntities.includes('devices')) {
186
+ const rawDeps = await db.query.departments.findMany({
187
+ with: { hospital: true },
188
+ });
189
+
190
+ const formatted = rawDeps.map((d) => ({
191
+ name: d.name,
192
+ code: d.code,
193
+ type: d.departmentType,
194
+ hospital: d.hospital?.name || '',
195
+ status: d.status || 'active',
196
+ createdAt: d.createdAt?.toISOString() || '',
197
+ }));
198
+
199
+ const columns = [
200
+ { key: 'name', header: 'Department Name' },
201
+ { key: 'code', header: 'Code' },
202
+ { key: 'type', header: 'Department Type' },
203
+ { key: 'hospital', header: 'Hospital Name' },
204
+ { key: 'status', header: 'Status' },
205
+ { key: 'createdAt', header: 'Created At' },
206
+ ];
207
+
208
+ const csvContent = generateCSV(formatted, columns);
209
+
210
+ return new NextResponse(csvContent, {
211
+ headers: {
212
+ 'Content-Type': 'text/csv; charset=utf-8',
213
+ 'Content-Disposition': `attachment; filename="bemms_departments_${timestamp}.csv"`,
214
+ },
215
+ });
216
+ }
217
+
218
+ if (selectedEntities.includes('rounds')) {
219
+ const rawRounds = await db.query.engineerRounds.findMany({
220
+ with: { engineer: true, hospital: true },
221
+ });
222
+
223
+ const formatted = rawRounds.map((r) => ({
224
+ roundDate: r.roundDate,
225
+ shiftType: r.shiftType,
226
+ engineerName: r.engineer?.fullName || '',
227
+ hospitalName: r.hospital?.name || '',
228
+ status: r.status,
229
+ startedAt: r.startedAt?.toISOString() || '',
230
+ completedAt: r.completedAt?.toISOString() || '',
231
+ totalDepartments: r.totalDepartments,
232
+ completedDepartments: r.completedDepartments,
233
+ totalIssuesReported: r.totalIssuesReported,
234
+ totalNotes: r.totalNotes,
235
+ }));
236
+
237
+ const columns = [
238
+ { key: 'roundDate', header: 'Round Date' },
239
+ { key: 'shiftType', header: 'Shift' },
240
+ { key: 'engineerName', header: 'Engineer Name' },
241
+ { key: 'hospitalName', header: 'Hospital' },
242
+ { key: 'status', header: 'Status' },
243
+ { key: 'startedAt', header: 'Started At' },
244
+ { key: 'completedAt', header: 'Completed At' },
245
+ { key: 'totalDepartments', header: 'Total Depts' },
246
+ { key: 'completedDepartments', header: 'Completed Depts' },
247
+ { key: 'totalIssuesReported', header: 'Issues Reported' },
248
+ { key: 'totalNotes', header: 'Notes' },
249
+ ];
250
+
251
+ const csvContent = generateCSV(formatted, columns);
252
+
253
+ return new NextResponse(csvContent, {
254
+ headers: {
255
+ 'Content-Type': 'text/csv; charset=utf-8',
256
+ 'Content-Disposition': `attachment; filename="bemms_engineer_rounds_${timestamp}.csv"`,
257
+ },
258
+ });
259
+ }
260
+
261
+ // Default to devices
262
+ const rawDevices = await db.query.devices.findMany({
263
+ with: {
264
+ deviceCategory: true,
265
+ manufacturer: true,
266
+ department: true,
267
+ },
268
+ });
269
+
270
+ const formatted = rawDevices.map((d) => ({
271
+ assetNumber: d.assetNumber,
272
+ name: d.name,
273
+ model: d.modelNameFree || '',
274
+ serialNumber: d.serialNumber || '',
275
+ category: d.deviceCategory?.name || '',
276
+ manufacturer: d.manufacturer?.name || '',
277
+ department: d.department?.name || '',
278
+ location: d.exactLocationDescription || '',
279
+ status: d.currentStatusCode,
280
+ criticality: d.criticalityLevel || 'medium',
281
+ riskClassification: d.riskClassification || '',
282
+ }));
283
+
284
+ const columns = [
285
+ { key: 'assetNumber', header: 'Asset Number' },
286
+ { key: 'name', header: 'Device Name' },
287
+ { key: 'model', header: 'Model' },
288
+ { key: 'serialNumber', header: 'Serial Number' },
289
+ { key: 'category', header: 'Category' },
290
+ { key: 'manufacturer', header: 'Manufacturer' },
291
+ { key: 'department', header: 'Department' },
292
+ { key: 'location', header: 'Location' },
293
+ { key: 'status', header: 'Status' },
294
+ { key: 'criticality', header: 'Criticality' },
295
+ { key: 'riskClassification', header: 'Risk Class' },
296
+ ];
297
+
298
+ const csvContent = generateCSV(formatted, columns);
299
+
300
+ return new NextResponse(csvContent, {
301
+ headers: {
302
+ 'Content-Type': 'text/csv; charset=utf-8',
303
+ 'Content-Disposition': `attachment; filename="bemms_devices_${timestamp}.csv"`,
304
+ },
305
+ });
306
+ }
307
+
308
+ return new NextResponse('Export complete', { status: 200 });
309
+ } catch (error: any) {
310
+ return new NextResponse(JSON.stringify({ error: error.message || 'Export failed' }), {
311
+ status: 500,
312
+ headers: { 'Content-Type': 'application/json' },
313
+ });
314
+ }
315
+ }
316
+
@@ -0,0 +1,3 @@
1
+ import { handlers } from '@/lib/auth';
2
+
3
+ export const { GET, POST } = handlers;
@@ -0,0 +1,46 @@
1
+ import { NextResponse } from 'next/server';
2
+ import { db } from '@/lib/db';
3
+ import { sql } from 'drizzle-orm';
4
+ import { getLogger } from '@/lib/logger';
5
+
6
+ const log = getLogger('health');
7
+
8
+ /**
9
+ * Production health check endpoint.
10
+ * Used by Docker HEALTHCHECK, load balancers, and monitoring tools.
11
+ * Returns 200 if the app and database are healthy, 503 otherwise.
12
+ */
13
+ export async function GET() {
14
+ const startTime = Date.now();
15
+
16
+ try {
17
+ // Check database connectivity
18
+ await db.execute(sql`SELECT 1`);
19
+
20
+ const responseTime = Date.now() - startTime;
21
+
22
+ return NextResponse.json(
23
+ {
24
+ status: 'healthy',
25
+ timestamp: new Date().toISOString(),
26
+ uptime: process.uptime(),
27
+ database: 'connected',
28
+ responseTimeMs: responseTime,
29
+ },
30
+ { status: 200 }
31
+ );
32
+ } catch (error: any) {
33
+ const responseTime = Date.now() - startTime;
34
+ log.error('Health check failed', error);
35
+
36
+ return NextResponse.json(
37
+ {
38
+ status: 'unhealthy',
39
+ timestamp: new Date().toISOString(),
40
+ database: 'disconnected',
41
+ responseTimeMs: responseTime,
42
+ },
43
+ { status: 503 }
44
+ );
45
+ }
46
+ }
@@ -0,0 +1,91 @@
1
+ import { NextRequest } from 'next/server';
2
+ import { auth } from '@/lib/auth';
3
+ import { db } from '@/lib/db';
4
+ import { notifications } from '@/lib/db/schema';
5
+ import { eq, and, isNull, desc, sql, gt } from 'drizzle-orm';
6
+
7
+ export const dynamic = 'force-dynamic';
8
+
9
+ export async function GET(req: NextRequest) {
10
+ const session = await auth();
11
+ if (!session?.user?.id) {
12
+ return new Response('Unauthorized', { status: 401 });
13
+ }
14
+
15
+ const userId = (session.user as any).id;
16
+
17
+ const encoder = new TextEncoder();
18
+ let intervalId: NodeJS.Timeout;
19
+ let lastChecked = new Date();
20
+
21
+ const stream = new ReadableStream({
22
+ start(controller) {
23
+ const poll = async () => {
24
+ try {
25
+ // 1. Send unread count
26
+ const countResult = await db.select({
27
+ count: sql<number>`count(*)::int`,
28
+ })
29
+ .from(notifications)
30
+ .where(and(
31
+ eq(notifications.recipientUserId, userId),
32
+ isNull(notifications.readAt),
33
+ ));
34
+
35
+ const count = countResult[0]?.count || 0;
36
+ const countData = `data: ${JSON.stringify({ type: 'unread_count', count })}\n\n`;
37
+ controller.enqueue(encoder.encode(countData));
38
+
39
+ // 2. Check for new notifications since last poll
40
+ const newNotifications = await db.select({
41
+ id: notifications.id,
42
+ notificationType: notifications.notificationType,
43
+ title: notifications.title,
44
+ body: notifications.body,
45
+ actionUrl: notifications.actionUrl,
46
+ relatedEntityType: notifications.relatedEntityType,
47
+ relatedEntityId: notifications.relatedEntityId,
48
+ createdAt: notifications.createdAt,
49
+ })
50
+ .from(notifications)
51
+ .where(and(
52
+ eq(notifications.recipientUserId, userId),
53
+ gt(notifications.createdAt, lastChecked),
54
+ ))
55
+ .orderBy(desc(notifications.createdAt))
56
+ .limit(5);
57
+
58
+ if (newNotifications.length > 0) {
59
+ const eventData = `data: ${JSON.stringify({
60
+ type: 'new_notifications',
61
+ notifications: newNotifications,
62
+ })}\n\n`;
63
+ controller.enqueue(encoder.encode(eventData));
64
+ }
65
+
66
+ lastChecked = new Date();
67
+ } catch (error) {
68
+ // Connection closed
69
+ clearInterval(intervalId);
70
+ }
71
+ };
72
+
73
+ // Send immediately
74
+ poll();
75
+
76
+ // Poll every 5 seconds for faster real-time feel
77
+ intervalId = setInterval(poll, 5000);
78
+ },
79
+ cancel() {
80
+ clearInterval(intervalId);
81
+ },
82
+ });
83
+
84
+ return new Response(stream, {
85
+ headers: {
86
+ 'Content-Type': 'text/event-stream',
87
+ 'Cache-Control': 'no-cache',
88
+ 'Connection': 'keep-alive',
89
+ },
90
+ });
91
+ }