@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.
- package/LICENSE +22 -0
- package/README.md +203 -2
- package/drizzle.config.ts +11 -0
- package/next.config.ts +51 -0
- package/package.json +99 -3
- package/postcss.config.mjs +7 -0
- package/public/hospital_demo_devices.csv +32 -0
- package/public/icons/icon-192.png +0 -0
- package/public/icons/icon-512.png +0 -0
- package/public/icons/icon-maskable-512.png +0 -0
- package/public/manifest.webmanifest +31 -0
- package/public/sw.js +177 -0
- package/src/app/(auth)/layout.tsx +20 -0
- package/src/app/(auth)/login/page.tsx +135 -0
- package/src/app/(dashboard)/admin/audit-logs/page.tsx +178 -0
- package/src/app/(dashboard)/admin/data/page.tsx +37 -0
- package/src/app/(dashboard)/admin/departments/[deptId]/edit/page.tsx +206 -0
- package/src/app/(dashboard)/admin/departments/[deptId]/page.tsx +71 -0
- package/src/app/(dashboard)/admin/departments/new/page.tsx +6 -0
- package/src/app/(dashboard)/admin/departments/page.tsx +6 -0
- package/src/app/(dashboard)/admin/general/page.tsx +374 -0
- package/src/app/(dashboard)/admin/hospitals/[hospitalId]/edit/page.tsx +304 -0
- package/src/app/(dashboard)/admin/hospitals/[hospitalId]/page.tsx +94 -0
- package/src/app/(dashboard)/admin/hospitals/new/page.tsx +302 -0
- package/src/app/(dashboard)/admin/hospitals/page.tsx +6 -0
- package/src/app/(dashboard)/admin/layout.tsx +49 -0
- package/src/app/(dashboard)/admin/organizations/[orgId]/edit/page.tsx +172 -0
- package/src/app/(dashboard)/admin/organizations/[orgId]/page.tsx +82 -0
- package/src/app/(dashboard)/admin/organizations/new/page.tsx +150 -0
- package/src/app/(dashboard)/admin/organizations/page.tsx +6 -0
- package/src/app/(dashboard)/admin/page.tsx +258 -0
- package/src/app/(dashboard)/admin/roles/page.tsx +153 -0
- package/src/app/(dashboard)/admin/settings/page.tsx +69 -0
- package/src/app/(dashboard)/admin/users/[userId]/edit/page.tsx +153 -0
- package/src/app/(dashboard)/admin/users/[userId]/page.tsx +317 -0
- package/src/app/(dashboard)/admin/users/invite/page.tsx +3 -0
- package/src/app/(dashboard)/admin/users/new/page.tsx +5 -0
- package/src/app/(dashboard)/admin/users/page.tsx +23 -0
- package/src/app/(dashboard)/dashboard/page.tsx +462 -0
- package/src/app/(dashboard)/departments/[deptId]/page.tsx +39 -0
- package/src/app/(dashboard)/departments/new/page.tsx +330 -0
- package/src/app/(dashboard)/departments/page.tsx +52 -0
- package/src/app/(dashboard)/devices/[id]/edit/page.tsx +131 -0
- package/src/app/(dashboard)/devices/[id]/page.tsx +408 -0
- package/src/app/(dashboard)/devices/[id]/qr/page.tsx +54 -0
- package/src/app/(dashboard)/devices/new/page.tsx +382 -0
- package/src/app/(dashboard)/devices/page.tsx +80 -0
- package/src/app/(dashboard)/engineering-team/page.tsx +41 -0
- package/src/app/(dashboard)/layout.tsx +43 -0
- package/src/app/(dashboard)/maintenance/checklists/page.tsx +57 -0
- package/src/app/(dashboard)/maintenance/page.tsx +231 -0
- package/src/app/(dashboard)/maintenance/plans/page.tsx +142 -0
- package/src/app/(dashboard)/maintenance/tasks/[id]/page.tsx +997 -0
- package/src/app/(dashboard)/maintenance/tasks/page.tsx +119 -0
- package/src/app/(dashboard)/more/page.tsx +310 -0
- package/src/app/(dashboard)/my-department/page.tsx +151 -0
- package/src/app/(dashboard)/notifications/notification-list.tsx +103 -0
- package/src/app/(dashboard)/notifications/page.tsx +59 -0
- package/src/app/(dashboard)/profile/page.tsx +27 -0
- package/src/app/(dashboard)/reports/costs/page.tsx +127 -0
- package/src/app/(dashboard)/reports/history/page.tsx +105 -0
- package/src/app/(dashboard)/reports/inventory/page.tsx +116 -0
- package/src/app/(dashboard)/reports/maintenance/page.tsx +136 -0
- package/src/app/(dashboard)/reports/page.tsx +120 -0
- package/src/app/(dashboard)/reports/tickets/page.tsx +139 -0
- package/src/app/(dashboard)/reports/workload/page.tsx +135 -0
- package/src/app/(dashboard)/rounds/[id]/page.tsx +197 -0
- package/src/app/(dashboard)/rounds/history/page.tsx +82 -0
- package/src/app/(dashboard)/rounds/page.tsx +107 -0
- package/src/app/(dashboard)/scan/[code]/page.tsx +154 -0
- package/src/app/(dashboard)/scan/page.tsx +73 -0
- package/src/app/(dashboard)/team/page.tsx +42 -0
- package/src/app/(dashboard)/tickets/[id]/page.tsx +465 -0
- package/src/app/(dashboard)/tickets/create/page.tsx +722 -0
- package/src/app/(dashboard)/tickets/page.tsx +55 -0
- package/src/app/(dashboard)/tickets/triage/page.tsx +161 -0
- package/src/app/api/admin/data/export/route.ts +316 -0
- package/src/app/api/auth/[...nextauth]/route.ts +3 -0
- package/src/app/api/health/route.ts +46 -0
- package/src/app/api/notifications/sse/route.ts +91 -0
- package/src/app/api/notifications/subscribe/route.ts +54 -0
- package/src/app/api/notifications/vapid-key/route.ts +7 -0
- package/src/app/api/reports/export/route.ts +102 -0
- package/src/app/api/tickets/[id]/attachments/route.ts +129 -0
- package/src/app/api/tickets/[id]/live-sync/route.ts +52 -0
- package/src/app/error.tsx +54 -0
- package/src/app/globals.css +89 -0
- package/src/app/layout.tsx +59 -0
- package/src/app/not-found.tsx +32 -0
- package/src/app/page.tsx +5 -0
- package/src/components/admin/add-department-user-dialog.tsx +759 -0
- package/src/components/admin/add-engineer-dialog.tsx +773 -0
- package/src/components/admin/add-user-type-dialog.tsx +89 -0
- package/src/components/admin/audit-log-export.tsx +113 -0
- package/src/components/admin/audit-log-filters.tsx +255 -0
- package/src/components/admin/audit-log-table.tsx +383 -0
- package/src/components/admin/data-management/danger-zone.tsx +301 -0
- package/src/components/admin/data-management/data-management-tabs.tsx +61 -0
- package/src/components/admin/data-management/export-center.tsx +301 -0
- package/src/components/admin/data-management/import-wizard.tsx +558 -0
- package/src/components/admin/settings-forms/features-form.tsx +115 -0
- package/src/components/admin/settings-forms/health-panel.tsx +182 -0
- package/src/components/admin/settings-forms/localization-form.tsx +203 -0
- package/src/components/admin/settings-forms/retention-form.tsx +164 -0
- package/src/components/admin/settings-forms/security-form.tsx +186 -0
- package/src/components/admin/system-settings-tabs.tsx +88 -0
- package/src/components/admin/user-role-dialog.tsx +293 -0
- package/src/components/admin/user-status-dialog.tsx +166 -0
- package/src/components/admin/users-page-client.tsx +514 -0
- package/src/components/dashboard/action-buttons.tsx +93 -0
- package/src/components/dashboard/admin-alerts.tsx +86 -0
- package/src/components/dashboard/availability-alerts.tsx +65 -0
- package/src/components/dashboard/maintenance-due-list.tsx +87 -0
- package/src/components/dashboard/round-widget.tsx +110 -0
- package/src/components/dashboard/stat-card.tsx +38 -0
- package/src/components/dashboard/ticket-list-compact.tsx +86 -0
- package/src/components/departments/department-cards-view.tsx +244 -0
- package/src/components/departments/department-detail-view.tsx +467 -0
- package/src/components/departments/department-visuals.tsx +190 -0
- package/src/components/departments/my-department-view.tsx +333 -0
- package/src/components/devices/change-status-dialog.tsx +146 -0
- package/src/components/devices/device-action-buttons.tsx +125 -0
- package/src/components/devices/device-availability-banner.tsx +59 -0
- package/src/components/devices/device-badges.tsx +55 -0
- package/src/components/devices/device-documents-tab.tsx +93 -0
- package/src/components/devices/device-maintenance-tab.tsx +113 -0
- package/src/components/devices/device-mini-qr.tsx +109 -0
- package/src/components/devices/device-qr-card.tsx +187 -0
- package/src/components/devices/device-signatures-tab.tsx +607 -0
- package/src/components/devices/device-tickets-tab.tsx +139 -0
- package/src/components/devices/device-timelines.tsx +124 -0
- package/src/components/devices/qr-camera-scanner.tsx +167 -0
- package/src/components/devices/qr-code-display.tsx +139 -0
- package/src/components/devices/quick-add-category-dialog.tsx +135 -0
- package/src/components/devices/quick-add-manufacturer-dialog.tsx +212 -0
- package/src/components/devices/transfer-device-dialog.tsx +190 -0
- package/src/components/layout/header.tsx +80 -0
- package/src/components/layout/mobile-nav.tsx +91 -0
- package/src/components/layout/more-item-badge.tsx +15 -0
- package/src/components/layout/nav-badge.tsx +72 -0
- package/src/components/layout/sidebar.tsx +297 -0
- package/src/components/maintenance/maintenance-badges.tsx +579 -0
- package/src/components/maintenance/maintenance-nav.tsx +107 -0
- package/src/components/maintenance/parts-costs-manager.tsx +909 -0
- package/src/components/maintenance/signature-card.tsx +413 -0
- package/src/components/maintenance/task-action-bar.tsx +955 -0
- package/src/components/notifications/notification-bell.tsx +367 -0
- package/src/components/notifications/notification-item.tsx +93 -0
- package/src/components/notifications/notification-preferences.tsx +182 -0
- package/src/components/profile/user-profile-view.tsx +626 -0
- package/src/components/providers/nav-counts-provider.tsx +235 -0
- package/src/components/providers/session-provider.tsx +7 -0
- package/src/components/providers/theme-provider.tsx +124 -0
- package/src/components/pwa/install-prompt.tsx +82 -0
- package/src/components/pwa/sw-register.tsx +43 -0
- package/src/components/reports/export-button.tsx +62 -0
- package/src/components/reports/filter-chips.tsx +47 -0
- package/src/components/reports/summary-cards.tsx +40 -0
- package/src/components/reports/transparency-header.tsx +54 -0
- package/src/components/rounds/department-visit-sheet.tsx +199 -0
- package/src/components/rounds/mention-textarea.tsx +187 -0
- package/src/components/rounds/round-calendar.tsx +233 -0
- package/src/components/rounds/round-department-list.tsx +281 -0
- package/src/components/rounds/round-history-client.tsx +218 -0
- package/src/components/rounds/start-round-card.tsx +184 -0
- package/src/components/shared/confirm-dialog.tsx +67 -0
- package/src/components/shared/data-table.tsx +104 -0
- package/src/components/shared/page-header.tsx +20 -0
- package/src/components/shared/sign-out-button.tsx +52 -0
- package/src/components/shared/status-badge.tsx +42 -0
- package/src/components/shared/table-search.tsx +55 -0
- package/src/components/signatures/sign-action-dialog.tsx +253 -0
- package/src/components/signatures/signature-history.tsx +121 -0
- package/src/components/signatures/signature-status-panel.tsx +164 -0
- package/src/components/team/department-coverage.tsx +141 -0
- package/src/components/team/edit-engineer-dialog.tsx +242 -0
- package/src/components/team/engineer-card.tsx +180 -0
- package/src/components/team/engineer-details-dialog.tsx +432 -0
- package/src/components/team/schedule-calendar.tsx +204 -0
- package/src/components/team/staff-engineer-team-view.tsx +368 -0
- package/src/components/team/team-header-stats.tsx +88 -0
- package/src/components/team/team-page-client.tsx +216 -0
- package/src/components/theme/theme-selector-card.tsx +63 -0
- package/src/components/theme/theme-toggle.tsx +65 -0
- package/src/components/tickets/staff-status-label.tsx +58 -0
- package/src/components/tickets/staff-ticket-card.tsx +109 -0
- package/src/components/tickets/staff-ticket-progress-tracker.tsx +405 -0
- package/src/components/tickets/staff-tickets-view.tsx +182 -0
- package/src/components/tickets/ticket-action-buttons.tsx +873 -0
- package/src/components/tickets/ticket-auto-refresh.tsx +197 -0
- package/src/components/tickets/ticket-badges.tsx +43 -0
- package/src/components/tickets/ticket-card.tsx +197 -0
- package/src/components/tickets/ticket-cards-view.tsx +395 -0
- package/src/components/tickets/ticket-comment-section.tsx +684 -0
- package/src/components/tickets/ticket-dispatch-status.tsx +184 -0
- package/src/components/tickets/ticket-list-auto-refresh.tsx +55 -0
- package/src/components/tickets/ticket-reporter-profile.tsx +235 -0
- package/src/components/tickets/ticket-signatures-panel.tsx +32 -0
- package/src/components/tickets/ticket-timeline.tsx +131 -0
- package/src/components/tickets/ticket-triage-panel.tsx +124 -0
- package/src/components/tickets/tickets-nav.tsx +107 -0
- package/src/components/ui/avatar.tsx +43 -0
- package/src/components/ui/badge.tsx +34 -0
- package/src/components/ui/button.tsx +58 -0
- package/src/components/ui/card.tsx +77 -0
- package/src/components/ui/checkbox.tsx +31 -0
- package/src/components/ui/dialog.tsx +123 -0
- package/src/components/ui/dropdown-menu.tsx +126 -0
- package/src/components/ui/form.tsx +179 -0
- package/src/components/ui/input.tsx +24 -0
- package/src/components/ui/label.tsx +27 -0
- package/src/components/ui/popover.tsx +34 -0
- package/src/components/ui/progress.tsx +36 -0
- package/src/components/ui/scroll-area.tsx +47 -0
- package/src/components/ui/select.tsx +160 -0
- package/src/components/ui/separator.tsx +25 -0
- package/src/components/ui/sheet.tsx +131 -0
- package/src/components/ui/switch.tsx +30 -0
- package/src/components/ui/table.tsx +121 -0
- package/src/components/ui/tabs.tsx +62 -0
- package/src/components/ui/textarea.tsx +23 -0
- package/src/components/ui/tooltip.tsx +27 -0
- package/src/lib/actions/__tests__/devices.test.ts +176 -0
- package/src/lib/actions/__tests__/signatures.test.ts +303 -0
- package/src/lib/actions/__tests__/tickets.test.ts +243 -0
- package/src/lib/actions/audit-logs.ts +298 -0
- package/src/lib/actions/checklists.ts +429 -0
- package/src/lib/actions/dashboard.ts +358 -0
- package/src/lib/actions/data-management.ts +723 -0
- package/src/lib/actions/departments.ts +369 -0
- package/src/lib/actions/device-categories.ts +117 -0
- package/src/lib/actions/devices.ts +748 -0
- package/src/lib/actions/engineer-rounds.ts +973 -0
- package/src/lib/actions/engineers.ts +285 -0
- package/src/lib/actions/hospitals.ts +148 -0
- package/src/lib/actions/locations.ts +115 -0
- package/src/lib/actions/maintenance-plans.ts +316 -0
- package/src/lib/actions/maintenance-tasks.ts +415 -0
- package/src/lib/actions/manufacturers.ts +132 -0
- package/src/lib/actions/nav-counts.ts +144 -0
- package/src/lib/actions/notifications.ts +136 -0
- package/src/lib/actions/organizations.ts +99 -0
- package/src/lib/actions/qr.ts +147 -0
- package/src/lib/actions/report-history.ts +77 -0
- package/src/lib/actions/reports.ts +192 -0
- package/src/lib/actions/roles.ts +32 -0
- package/src/lib/actions/signature-auth.ts +377 -0
- package/src/lib/actions/signatures.ts +532 -0
- package/src/lib/actions/system-settings.ts +274 -0
- package/src/lib/actions/tickets.ts +1445 -0
- package/src/lib/actions/users.ts +740 -0
- package/src/lib/audit/index.ts +103 -0
- package/src/lib/auth/__tests__/rbac.test.ts +33 -0
- package/src/lib/auth/index.ts +338 -0
- package/src/lib/auth/password.ts +10 -0
- package/src/lib/auth/rbac.ts +103 -0
- package/src/lib/auth/types.ts +57 -0
- package/src/lib/constants/departments.ts +16 -0
- package/src/lib/constants/manufacturers.ts +32 -0
- package/src/lib/db/index.ts +23 -0
- package/src/lib/db/schema/audit-logs.ts +42 -0
- package/src/lib/db/schema/departments.ts +39 -0
- package/src/lib/db/schema/device-categories.ts +38 -0
- package/src/lib/db/schema/devices.ts +195 -0
- package/src/lib/db/schema/engineer-profiles.ts +63 -0
- package/src/lib/db/schema/engineer-rounds.ts +115 -0
- package/src/lib/db/schema/enums.ts +148 -0
- package/src/lib/db/schema/hospitals.ts +34 -0
- package/src/lib/db/schema/index.ts +24 -0
- package/src/lib/db/schema/locations.ts +46 -0
- package/src/lib/db/schema/maintenance-plans.ts +99 -0
- package/src/lib/db/schema/maintenance-records.ts +161 -0
- package/src/lib/db/schema/maintenance-tasks.ts +196 -0
- package/src/lib/db/schema/manufacturers.ts +60 -0
- package/src/lib/db/schema/notifications.ts +48 -0
- package/src/lib/db/schema/organizations.ts +24 -0
- package/src/lib/db/schema/report-history.ts +28 -0
- package/src/lib/db/schema/roles.ts +59 -0
- package/src/lib/db/schema/sessions.ts +46 -0
- package/src/lib/db/schema/signature-events.ts +48 -0
- package/src/lib/db/schema/signature-policies.ts +60 -0
- package/src/lib/db/schema/signatures.ts +87 -0
- package/src/lib/db/schema/tickets.ts +169 -0
- package/src/lib/db/schema/user-assignments.ts +85 -0
- package/src/lib/db/schema/users.ts +39 -0
- package/src/lib/db/seed.ts +131 -0
- package/src/lib/logger/__tests__/logger.test.ts +154 -0
- package/src/lib/logger/formatters.ts +159 -0
- package/src/lib/logger/index.ts +184 -0
- package/src/lib/logger/types.ts +48 -0
- package/src/lib/notifications/create.ts +71 -0
- package/src/lib/notifications/dispatcher.ts +490 -0
- package/src/lib/notifications/index.ts +7 -0
- package/src/lib/notifications/push-sender.ts +128 -0
- package/src/lib/notifications/push.ts +64 -0
- package/src/lib/notifications/triggers.ts +166 -0
- package/src/lib/utils/__tests__/crypto.test.ts +30 -0
- package/src/lib/utils/__tests__/export.test.ts +55 -0
- package/src/lib/utils/__tests__/nav-badge.test.ts +147 -0
- package/src/lib/utils/__tests__/staff-status.test.ts +50 -0
- package/src/lib/utils/attestations.ts +19 -0
- package/src/lib/utils/crypto.ts +18 -0
- package/src/lib/utils/csv-parser.ts +181 -0
- package/src/lib/utils/export.ts +45 -0
- package/src/lib/utils/nav-badge.ts +225 -0
- package/src/lib/utils/sound.ts +152 -0
- package/src/lib/utils/staff-status.ts +115 -0
- package/src/lib/utils.ts +6 -0
- package/src/lib/validators/__tests__/auth.test.ts +127 -0
- package/src/lib/validators/__tests__/data-management.test.ts +182 -0
- package/src/lib/validators/__tests__/devices-profile.test.ts +129 -0
- package/src/lib/validators/__tests__/devices.test.ts +98 -0
- package/src/lib/validators/__tests__/engineer-rounds.test.ts +170 -0
- package/src/lib/validators/__tests__/maintenance.test.ts +178 -0
- package/src/lib/validators/__tests__/signatures.test.ts +154 -0
- package/src/lib/validators/__tests__/system-settings.test.ts +119 -0
- package/src/lib/validators/__tests__/tickets.test.ts +176 -0
- package/src/lib/validators/__tests__/users.test.ts +92 -0
- package/src/lib/validators/auth.ts +32 -0
- package/src/lib/validators/data-management.ts +67 -0
- package/src/lib/validators/departments.ts +14 -0
- package/src/lib/validators/devices.ts +94 -0
- package/src/lib/validators/engineer-rounds.ts +70 -0
- package/src/lib/validators/hospitals.ts +17 -0
- package/src/lib/validators/locations.ts +15 -0
- package/src/lib/validators/maintenance.ts +211 -0
- package/src/lib/validators/organizations.ts +11 -0
- package/src/lib/validators/reports.ts +31 -0
- package/src/lib/validators/signatures.ts +49 -0
- package/src/lib/validators/system-settings.ts +55 -0
- package/src/lib/validators/tickets.ts +125 -0
- package/src/lib/validators/users.ts +43 -0
- package/src/middleware.ts +45 -0
- package/tailwind.config.ts +76 -0
- 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,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
|
+
|