@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,103 @@
1
+ import { db } from '@/lib/db';
2
+ import { auditLogs } from '@/lib/db/schema';
3
+ import { getLogger } from '@/lib/logger';
4
+
5
+ const log = getLogger('audit');
6
+
7
+ export interface AuditLogEntry {
8
+ actorUserId?: string | null;
9
+ actorId?: string | null;
10
+ organizationId?: string | null;
11
+ hospitalId?: string | null;
12
+ entityType: string;
13
+ entityId: string;
14
+ actionType?: any;
15
+ action?: any;
16
+ previousState?: unknown;
17
+ newState?: unknown;
18
+ details?: unknown;
19
+ changeReason?: string;
20
+ ipAddress?: string;
21
+ sessionId?: string;
22
+ }
23
+
24
+ const VALID_AUDIT_ACTIONS = new Set([
25
+ 'CREATE', 'UPDATE', 'DEACTIVATE', 'ARCHIVE', 'TRANSFER', 'PURGE', 'SIGN', 'POLICY_CHANGE', 'LOGIN', 'LOGOUT', 'FAILED_LOGIN'
26
+ ]);
27
+
28
+ function normalizeAuditAction(action?: string): string {
29
+ if (!action) return 'CREATE';
30
+ const upper = String(action).toUpperCase();
31
+ if (VALID_AUDIT_ACTIONS.has(upper)) return upper;
32
+ if (upper.includes('IMPORT') || upper.includes('INSERT') || upper.includes('INVITE') || upper.includes('ASSIGN')) return 'CREATE';
33
+ if (upper.includes('PURGE') || upper.includes('RESET') || upper.includes('CLEAR')) return 'PURGE';
34
+ if (upper.includes('DEACTIVATE') || upper.includes('DELETE') || upper.includes('REMOVE') || upper.includes('REVOKE')) return 'DEACTIVATE';
35
+ if (upper.includes('ARCHIVE')) return 'ARCHIVE';
36
+ if (upper.includes('TRANSFER')) return 'TRANSFER';
37
+ if (upper.includes('SIGN')) return 'SIGN';
38
+ return 'UPDATE';
39
+ }
40
+
41
+ /**
42
+ * Creates an audit log entry in the database.
43
+ * Never throws an error — fails gracefully and logs errors to prevent disrupting application flow.
44
+ */
45
+ export async function createAuditLog(arg1: any, arg2?: any) {
46
+ let entry: AuditLogEntry;
47
+ let tx: any;
48
+
49
+ if (arg1 && typeof arg1 === 'object' && ('entityType' in arg1 || 'actionType' in arg1 || 'action' in arg1)) {
50
+ entry = arg1;
51
+ tx = arg2;
52
+ } else {
53
+ tx = arg1;
54
+ entry = arg2;
55
+ }
56
+
57
+ if (!entry) return;
58
+
59
+ const dbClient = tx || db;
60
+ const actionType = normalizeAuditAction(entry.actionType || entry.action) as any;
61
+ const actorUserId = entry.actorUserId || entry.actorId || null;
62
+ const newState = entry.newState !== undefined ? entry.newState : entry.details;
63
+
64
+ try {
65
+ const result = await dbClient.insert(auditLogs).values({
66
+ actorUserId,
67
+ organizationId: entry.organizationId || null,
68
+ hospitalId: entry.hospitalId || null,
69
+ entityType: entry.entityType,
70
+ entityId: entry.entityId,
71
+ actionType,
72
+ previousState: entry.previousState ? JSON.stringify(entry.previousState) : null,
73
+ newState: newState ? JSON.stringify(newState) : null,
74
+ changeReason: entry.changeReason || null,
75
+ ipAddress: entry.ipAddress || null,
76
+ sessionId: entry.sessionId || null,
77
+ });
78
+ log.debug('Audit log created', { entityType: entry.entityType, entityId: entry.entityId, action: actionType });
79
+ return result;
80
+ } catch (err) {
81
+ log.error('Failed to create audit log', err, { entityType: entry.entityType, entityId: entry.entityId });
82
+ // Don't throw — audit logging should never break the main flow
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Creates an audit log entry, automatically extracting IP address from request headers if available.
88
+ */
89
+ export async function createAuditLogFromRequest(entry: AuditLogEntry, request?: Request) {
90
+ let ipAddress = entry.ipAddress;
91
+
92
+ if (request && !ipAddress) {
93
+ const forwardedFor = request.headers.get('x-forwarded-for');
94
+ if (forwardedFor) {
95
+ ipAddress = forwardedFor.split(',')[0].trim();
96
+ }
97
+ }
98
+
99
+ return createAuditLog({
100
+ ...entry,
101
+ ipAddress,
102
+ });
103
+ }
@@ -0,0 +1,33 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+
3
+ vi.mock('@/lib/auth', () => ({
4
+ auth: vi.fn(),
5
+ }));
6
+
7
+ vi.mock('@/lib/db', () => ({
8
+ db: { query: {} },
9
+ }));
10
+
11
+ import { hasRole } from '../rbac';
12
+
13
+ describe('hasRole', () => {
14
+ it('returns true when user has the specified role', () => {
15
+ const userRoles = ['BIOMED_ENG', 'STAFF'];
16
+ expect(hasRole(userRoles, 'BIOMED_ENG')).toBe(true);
17
+ });
18
+
19
+ it('returns true when user has at least one of multiple requested roles', () => {
20
+ const userRoles = ['BIOMED_TECH'];
21
+ expect(hasRole(userRoles, 'BIOMED_ENG', 'BIOMED_TECH', 'SYS_ADMIN')).toBe(true);
22
+ });
23
+
24
+ it('returns false when user does not have the specified role', () => {
25
+ const userRoles = ['STAFF'];
26
+ expect(hasRole(userRoles, 'SYS_ADMIN', 'ORG_ADMIN')).toBe(false);
27
+ });
28
+
29
+ it('returns false for empty user roles', () => {
30
+ expect(hasRole([], 'SYS_ADMIN')).toBe(false);
31
+ });
32
+ });
33
+
@@ -0,0 +1,338 @@
1
+ import NextAuth from 'next-auth';
2
+ import Credentials from 'next-auth/providers/credentials';
3
+ import { DrizzleAdapter } from '@auth/drizzle-adapter';
4
+ import { db } from '@/lib/db';
5
+ import { users, accounts, sessions, userRoleAssignments, roles } from '@/lib/db/schema';
6
+ import { verifyPassword } from '@/lib/auth/password';
7
+ import { eq, and, or } from 'drizzle-orm';
8
+ import { getLogger } from '@/lib/logger';
9
+ import { createAuditLog } from '@/lib/audit';
10
+
11
+ const log = getLogger('auth');
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // In-memory rate limiter for login attempts (per identifier)
15
+ // ---------------------------------------------------------------------------
16
+ const LOGIN_MAX_ATTEMPTS = 5; // max attempts before lockout
17
+ const LOGIN_WINDOW_MS = 15 * 60 * 1000; // 15-minute sliding window
18
+
19
+ interface RateLimitEntry {
20
+ attempts: number;
21
+ firstAttemptAt: number;
22
+ lockedUntil: number | null;
23
+ }
24
+
25
+ const loginAttempts = new Map<string, RateLimitEntry>();
26
+
27
+ function checkRateLimit(identifier: string): { allowed: boolean; retryAfterMs?: number } {
28
+ const now = Date.now();
29
+ const entry = loginAttempts.get(identifier);
30
+
31
+ if (!entry) return { allowed: true };
32
+
33
+ // If currently locked out
34
+ if (entry.lockedUntil && now < entry.lockedUntil) {
35
+ return { allowed: false, retryAfterMs: entry.lockedUntil - now };
36
+ }
37
+
38
+ // If lock has expired, reset
39
+ if (entry.lockedUntil && now >= entry.lockedUntil) {
40
+ loginAttempts.delete(identifier);
41
+ return { allowed: true };
42
+ }
43
+
44
+ // If window has expired, reset
45
+ if (now - entry.firstAttemptAt > LOGIN_WINDOW_MS) {
46
+ loginAttempts.delete(identifier);
47
+ return { allowed: true };
48
+ }
49
+
50
+ if (entry.attempts >= LOGIN_MAX_ATTEMPTS) {
51
+ // Lock out for the remainder of the window
52
+ entry.lockedUntil = entry.firstAttemptAt + LOGIN_WINDOW_MS;
53
+ return { allowed: false, retryAfterMs: entry.lockedUntil - now };
54
+ }
55
+
56
+ return { allowed: true };
57
+ }
58
+
59
+ function recordFailedAttempt(identifier: string): void {
60
+ const now = Date.now();
61
+ const entry = loginAttempts.get(identifier);
62
+
63
+ if (!entry || now - entry.firstAttemptAt > LOGIN_WINDOW_MS) {
64
+ loginAttempts.set(identifier, { attempts: 1, firstAttemptAt: now, lockedUntil: null });
65
+ return;
66
+ }
67
+
68
+ entry.attempts += 1;
69
+
70
+ if (entry.attempts >= LOGIN_MAX_ATTEMPTS) {
71
+ entry.lockedUntil = entry.firstAttemptAt + LOGIN_WINDOW_MS;
72
+ log.warn('Account temporarily locked due to excessive failed login attempts', {
73
+ identifier,
74
+ attempts: entry.attempts,
75
+ lockedUntilMs: entry.lockedUntil,
76
+ });
77
+ }
78
+ }
79
+
80
+ function clearRateLimit(identifier: string): void {
81
+ loginAttempts.delete(identifier);
82
+ }
83
+
84
+ // Periodically clean up expired entries to prevent memory leak (every 30 min)
85
+ setInterval(() => {
86
+ const now = Date.now();
87
+ for (const [key, entry] of loginAttempts.entries()) {
88
+ if (now - entry.firstAttemptAt > LOGIN_WINDOW_MS) {
89
+ loginAttempts.delete(key);
90
+ }
91
+ }
92
+ }, 30 * 60 * 1000).unref();
93
+
94
+ // ---------------------------------------------------------------------------
95
+ // Sanitize login identifier — escape SQL LIKE pattern characters
96
+ // ---------------------------------------------------------------------------
97
+ function sanitizeLikeIdentifier(input: string): string {
98
+ return input.replace(/[%_\\]/g, (ch) => `\\${ch}`);
99
+ }
100
+
101
+ // ---------------------------------------------------------------------------
102
+ // NextAuth configuration
103
+ // ---------------------------------------------------------------------------
104
+ export const { handlers, signIn, signOut, auth } = NextAuth({
105
+ adapter: DrizzleAdapter(db, {
106
+ usersTable: users as any,
107
+ accountsTable: accounts as any,
108
+ sessionsTable: sessions as any,
109
+ }) as any,
110
+ session: {
111
+ strategy: 'jwt',
112
+ maxAge: 8 * 60 * 60, // 8 hours — one hospital shift
113
+ },
114
+ pages: { signIn: '/login' },
115
+ providers: [
116
+ Credentials({
117
+ credentials: {
118
+ email: { label: 'Username or Email', type: 'text' },
119
+ password: { label: 'Password', type: 'password' },
120
+ },
121
+ authorize: async (credentials) => {
122
+ if (!credentials?.email || !credentials?.password) {
123
+ return null;
124
+ }
125
+
126
+ const identifier = (credentials.email as string).trim();
127
+ const sanitized = sanitizeLikeIdentifier(identifier);
128
+
129
+ // Check rate limit before doing any work
130
+ const rateCheck = checkRateLimit(identifier.toLowerCase());
131
+ if (!rateCheck.allowed) {
132
+ log.warn('Login attempt blocked by rate limiter', {
133
+ identifier,
134
+ retryAfterMs: rateCheck.retryAfterMs,
135
+ });
136
+ return null;
137
+ }
138
+
139
+ let userResult;
140
+ try {
141
+ userResult = await db.query.users.findFirst({
142
+ where: or(
143
+ eq(users.email, sanitized),
144
+ eq(users.email, `${sanitized}@bemms.local`),
145
+ eq(users.employeeIdentifier, sanitized)
146
+ ),
147
+ });
148
+ } catch (dbError: any) {
149
+ log.error('Database connection error during login', dbError);
150
+ throw new Error('DATABASE_CONNECTION_ERROR');
151
+ }
152
+
153
+ if (!userResult || !userResult.passwordHash) {
154
+ log.warn('Failed login attempt', { identifier });
155
+ recordFailedAttempt(identifier.toLowerCase());
156
+ try {
157
+ await createAuditLog({
158
+ entityType: 'user_session',
159
+ entityId: identifier,
160
+ actionType: 'FAILED_LOGIN',
161
+ newState: {
162
+ attemptedIdentifier: identifier,
163
+ reason: 'user_not_found',
164
+ attemptedAt: new Date().toISOString(),
165
+ },
166
+ });
167
+ } catch (auditErr) {
168
+ log.error('Failed to create failed-login audit log', auditErr);
169
+ }
170
+ return null;
171
+ }
172
+
173
+ if (userResult.accountStatus !== 'active') {
174
+ log.warn('Failed login attempt', { identifier });
175
+ recordFailedAttempt(identifier.toLowerCase());
176
+ try {
177
+ await createAuditLog({
178
+ actorUserId: userResult.id,
179
+ organizationId: userResult.organizationId,
180
+ entityType: 'user_session',
181
+ entityId: userResult.id,
182
+ actionType: 'FAILED_LOGIN',
183
+ newState: {
184
+ attemptedIdentifier: identifier,
185
+ reason: 'account_inactive',
186
+ attemptedAt: new Date().toISOString(),
187
+ },
188
+ });
189
+ } catch (auditErr) {
190
+ log.error('Failed to create failed-login audit log', auditErr);
191
+ }
192
+ return null;
193
+ }
194
+
195
+ const isPasswordValid = await verifyPassword(
196
+ credentials.password as string,
197
+ userResult.passwordHash
198
+ );
199
+
200
+ if (!isPasswordValid) {
201
+ log.warn('Failed login attempt', { identifier });
202
+ recordFailedAttempt(identifier.toLowerCase());
203
+ try {
204
+ await createAuditLog({
205
+ actorUserId: userResult.id,
206
+ organizationId: userResult.organizationId,
207
+ entityType: 'user_session',
208
+ entityId: userResult.id,
209
+ actionType: 'FAILED_LOGIN',
210
+ newState: {
211
+ attemptedIdentifier: identifier,
212
+ reason: 'invalid_credentials',
213
+ attemptedAt: new Date().toISOString(),
214
+ },
215
+ });
216
+ } catch (auditErr) {
217
+ log.error('Failed to create failed-login audit log', auditErr);
218
+ }
219
+ return null;
220
+ }
221
+
222
+ // Successful login — clear rate limit counter
223
+ clearRateLimit(identifier.toLowerCase());
224
+
225
+ return {
226
+ id: userResult.id,
227
+ email: userResult.email,
228
+ fullName: userResult.fullName,
229
+ organizationId: userResult.organizationId,
230
+ accountStatus: userResult.accountStatus,
231
+ avatarUrl: userResult.avatarUrl,
232
+ };
233
+ },
234
+ }),
235
+ ],
236
+ events: {
237
+ async signIn({ user, account }) {
238
+ // Log successful login
239
+ log.info('User signed in', { userId: user.id, email: user.email });
240
+
241
+ // Create audit log entry for LOGIN
242
+ try {
243
+ await createAuditLog({
244
+ actorUserId: user.id,
245
+ organizationId: (user as any).organizationId || null,
246
+ entityType: 'user_session',
247
+ entityId: user.id || 'unknown',
248
+ actionType: 'LOGIN',
249
+ newState: {
250
+ email: user.email,
251
+ fullName: (user as any).fullName,
252
+ loginAt: new Date().toISOString(),
253
+ provider: account?.provider || 'credentials',
254
+ },
255
+ });
256
+ } catch (err) {
257
+ log.error('Failed to create login audit log', err);
258
+ }
259
+
260
+ // Update lastLoginAt on the user record
261
+ try {
262
+ await db
263
+ .update(users)
264
+ .set({ lastLoginAt: new Date() })
265
+ .where(eq(users.id, user.id as string));
266
+ } catch (err) {
267
+ log.error('Failed to update lastLoginAt', err);
268
+ }
269
+ },
270
+ async signOut({ token }: any) {
271
+ // Log sign out
272
+ const userId = (token as any)?.id || (token as any)?.sub;
273
+ log.info('User signed out', { userId });
274
+
275
+ try {
276
+ await createAuditLog({
277
+ actorUserId: userId || null,
278
+ entityType: 'user_session',
279
+ entityId: userId || 'unknown',
280
+ actionType: 'LOGOUT',
281
+ newState: {
282
+ logoutAt: new Date().toISOString(),
283
+ },
284
+ });
285
+ } catch (err) {
286
+ log.error('Failed to create logout audit log', err);
287
+ }
288
+ },
289
+ },
290
+ callbacks: {
291
+ async jwt({ token, user, trigger }) {
292
+ if (user) {
293
+ token.id = user.id;
294
+ token.fullName = (user as any).fullName;
295
+ token.organizationId = (user as any).organizationId;
296
+ token.avatarUrl = (user as any).avatarUrl;
297
+ token.rolesLoadedAt = Date.now();
298
+ }
299
+
300
+ // Refresh roles from DB with a TTL of 5 minutes to reduce DB queries
301
+ const ROLE_REFRESH_TTL_MS = 5 * 60 * 1000;
302
+ const rolesLoadedAt = (token.rolesLoadedAt as number) || 0;
303
+ const shouldRefreshRoles = trigger === 'signIn' || Date.now() - rolesLoadedAt > ROLE_REFRESH_TTL_MS;
304
+
305
+ const userId = (token.id as string) || (token.sub as string) || (user?.id as string);
306
+ if (userId && shouldRefreshRoles) {
307
+ try {
308
+ const assignments = await db
309
+ .select({ code: roles.code })
310
+ .from(userRoleAssignments)
311
+ .innerJoin(roles, eq(userRoleAssignments.roleId, roles.id))
312
+ .where(and(
313
+ eq(userRoleAssignments.userId, userId),
314
+ eq(userRoleAssignments.status, 'active')
315
+ ));
316
+
317
+ token.roles = assignments.map((a: any) => a.code);
318
+ token.rolesLoadedAt = Date.now();
319
+ } catch (e) {
320
+ log.error('Failed to load user roles in jwt callback', e);
321
+ if (!token.roles) token.roles = [];
322
+ }
323
+ }
324
+
325
+ return token;
326
+ },
327
+ async session({ session, token }) {
328
+ if (session.user && token) {
329
+ session.user.id = (token.id as string) || (token.sub as string);
330
+ session.user.fullName = token.fullName as string;
331
+ session.user.organizationId = token.organizationId as string;
332
+ session.user.avatarUrl = token.avatarUrl as string;
333
+ session.user.roles = (token.roles as string[]) || [];
334
+ }
335
+ return session;
336
+ },
337
+ },
338
+ });
@@ -0,0 +1,10 @@
1
+ import bcrypt from 'bcryptjs';
2
+
3
+ export async function hashPassword(password: string): Promise<string> {
4
+ return bcrypt.hash(password, 12);
5
+ }
6
+
7
+ export async function verifyPassword(password: string, hash: string): Promise<boolean> {
8
+ return bcrypt.compare(password, hash);
9
+ }
10
+
@@ -0,0 +1,103 @@
1
+ import { auth } from '@/lib/auth';
2
+ import { db } from '@/lib/db';
3
+ import { rolePermissions, permissions, userAccessScopes, userRoleAssignments, roles as rolesTable } from '@/lib/db/schema';
4
+ import { eq, and, or } from 'drizzle-orm';
5
+ import { RoleCode, SessionUser } from '@/lib/auth/types';
6
+
7
+ export class AuthorizationError extends Error {
8
+ constructor(message: string = 'Unauthorized') {
9
+ super(message);
10
+ this.name = 'AuthorizationError';
11
+ }
12
+ }
13
+
14
+ export async function getCurrentUser(): Promise<SessionUser | null> {
15
+ const session = await auth();
16
+ return session?.user as SessionUser | null;
17
+ }
18
+
19
+ export async function requireAuth(): Promise<SessionUser> {
20
+ const user = await getCurrentUser();
21
+ if (!user) {
22
+ throw new AuthorizationError('Authentication required');
23
+ }
24
+ return user;
25
+ }
26
+
27
+ export function hasRole(userRoles: string[], ...roles: RoleCode[]): boolean {
28
+ return roles.some(role => userRoles.includes(role));
29
+ }
30
+
31
+ export async function requireRole(...roles: RoleCode[]): Promise<SessionUser> {
32
+ const user = await requireAuth();
33
+ if (!hasRole(user.roles, ...roles)) {
34
+ throw new AuthorizationError('Insufficient role permissions');
35
+ }
36
+ return user;
37
+ }
38
+
39
+ export async function requireScope(params: { organizationId?: string, hospitalId?: string, departmentId?: string, locationId?: string }): Promise<SessionUser> {
40
+ const user = await requireAuth();
41
+
42
+ if (user.roles.includes('SYS_ADMIN')) {
43
+ return user;
44
+ }
45
+
46
+ const scopes = await db.query.userAccessScopes.findMany({
47
+ where: eq(userAccessScopes.userId, user.id)
48
+ });
49
+
50
+ const hasScope = scopes.some(scope => {
51
+ let match = true;
52
+ if (params.organizationId && scope.organizationId && scope.organizationId !== params.organizationId) match = false;
53
+ if (params.hospitalId && scope.hospitalId && scope.hospitalId !== params.hospitalId) match = false;
54
+ if (params.departmentId && scope.departmentId && scope.departmentId !== params.departmentId) match = false;
55
+ if (params.locationId && scope.locationId && scope.locationId !== params.locationId) match = false;
56
+ return match;
57
+ });
58
+
59
+ if (!hasScope) {
60
+ throw new AuthorizationError('Insufficient access scope');
61
+ }
62
+
63
+ return user;
64
+ }
65
+
66
+ export async function requirePermission(module: string, action: string): Promise<SessionUser> {
67
+ const user = await requireAuth();
68
+
69
+ if (user.roles.includes('SYS_ADMIN')) {
70
+ return user;
71
+ }
72
+
73
+ const userRoles = await db
74
+ .select({ roleId: userRoleAssignments.roleId })
75
+ .from(userRoleAssignments)
76
+ .where(eq(userRoleAssignments.userId, user.id));
77
+
78
+ if (!userRoles.length) {
79
+ throw new AuthorizationError('No roles assigned');
80
+ }
81
+
82
+ const roleIds = userRoles.map(r => r.roleId);
83
+
84
+ const permCheck = await db
85
+ .select()
86
+ .from(rolePermissions)
87
+ .innerJoin(permissions, eq(rolePermissions.permissionId, permissions.id))
88
+ .where(
89
+ and(
90
+ or(...roleIds.map(id => eq(rolePermissions.roleId, id))),
91
+ eq(permissions.module, module as any),
92
+ eq(permissions.action, action as any)
93
+ )
94
+ )
95
+ .limit(1);
96
+
97
+ if (permCheck.length === 0) {
98
+ throw new AuthorizationError(`Missing permission: ${module}:${action}`);
99
+ }
100
+
101
+ return user;
102
+ }
103
+
@@ -0,0 +1,57 @@
1
+ import { DefaultSession, User } from 'next-auth';
2
+
3
+ declare module 'next-auth' {
4
+ interface Session {
5
+ user: {
6
+ id: string;
7
+ email: string;
8
+ fullName: string;
9
+ organizationId: string | null;
10
+ roles: string[];
11
+ avatarUrl: string | null;
12
+ } & DefaultSession["user"]
13
+ }
14
+
15
+ interface User {
16
+ fullName: string;
17
+ organizationId: string | null;
18
+ accountStatus: string;
19
+ avatarUrl: string | null;
20
+ }
21
+ }
22
+
23
+ export type RoleCode =
24
+ | 'SYS_ADMIN'
25
+ | 'ORG_ADMIN'
26
+ | 'HOSP_ADMIN'
27
+ | 'BIOMED_MGR'
28
+ | 'BIOMED_ENG'
29
+ | 'BIOMED_TECH'
30
+ | 'DEPT_MGR'
31
+ | 'STAFF'
32
+ | 'AUDITOR';
33
+
34
+ export type PermissionModule =
35
+ | 'USERS'
36
+ | 'ROLES'
37
+ | 'DEVICES'
38
+ | 'MAINTENANCE'
39
+ | 'WORK_ORDERS'
40
+ | 'SETTINGS';
41
+
42
+ export type PermissionAction =
43
+ | 'CREATE'
44
+ | 'READ'
45
+ | 'UPDATE'
46
+ | 'DELETE'
47
+ | 'APPROVE';
48
+
49
+ export interface SessionUser {
50
+ id: string;
51
+ email: string;
52
+ fullName: string;
53
+ organizationId: string | null;
54
+ roles: string[];
55
+ avatarUrl: string | null;
56
+ }
57
+
@@ -0,0 +1,16 @@
1
+ export interface DefaultDepartmentTemplate {
2
+ name: string;
3
+ code: string;
4
+ departmentType: string;
5
+ }
6
+
7
+ export const DEFAULT_HOSPITAL_DEPARTMENTS: DefaultDepartmentTemplate[] = [
8
+ { name: 'Emergency Department', code: 'EMERG', departmentType: 'Emergency' },
9
+ { name: 'Intensive Care Unit (ICU)', code: 'ICU', departmentType: 'Intensive Care' },
10
+ { name: 'Surgery & Operating Theatre', code: 'SURG', departmentType: 'Surgical' },
11
+ { name: 'Clinical Laboratory', code: 'LAB', departmentType: 'Laboratory' },
12
+ { name: 'Radiology & Medical Imaging', code: 'RAD', departmentType: 'Diagnostic & Imaging' },
13
+ { name: 'Pediatrics', code: 'PED', departmentType: 'Pediatrics' },
14
+ { name: 'Dental Clinic', code: 'DENT', departmentType: 'Dental' },
15
+ ];
16
+