@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.
- 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,1445 @@
|
|
|
1
|
+
'use server';
|
|
2
|
+
|
|
3
|
+
import { db } from '@/lib/db';
|
|
4
|
+
import {
|
|
5
|
+
serviceTickets, serviceTicketComments, serviceTicketEvents, devices,
|
|
6
|
+
recordVersions, electronicSignatures, signatureEvents, deviceStatusHistory,
|
|
7
|
+
userAccessScopes, hospitals, departments, users,
|
|
8
|
+
} from '@/lib/db/schema';
|
|
9
|
+
import { requireAuth, requireRole } from '@/lib/auth/rbac';
|
|
10
|
+
import { createAuditLog } from '@/lib/audit';
|
|
11
|
+
import {
|
|
12
|
+
createTicketSchema, triageTicketSchema, assignTicketSchema,
|
|
13
|
+
resolveTicketSchema, closeTicketSchema, cancelTicketSchema,
|
|
14
|
+
reopenTicketSchema, createTicketCommentSchema, acceptTicketSchema,
|
|
15
|
+
updateTicketDeviceStatusSchema, RESOLUTION_DEVICE_STATUSES,
|
|
16
|
+
} from '@/lib/validators/tickets';
|
|
17
|
+
import { eq, and, or, desc, ilike, notInArray, inArray } from 'drizzle-orm';
|
|
18
|
+
import { verifySignatureAuth, createSignatureEvent } from './signature-auth';
|
|
19
|
+
import { getAttestationText } from '@/lib/utils/attestations';
|
|
20
|
+
import { buildCanonicalSnapshot, computeSHA256 } from '@/lib/utils/crypto';
|
|
21
|
+
import { notifyEngineersOnNewTicket, notifyReporterOnAccept, notifyReporterOnStatusChange, notifyOnNewComment, notifyEngineerOnAssign } from '@/lib/notifications/dispatcher';
|
|
22
|
+
|
|
23
|
+
// ── Ticket Creation Defaults ──────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
export async function getTicketCreationDefaults() {
|
|
26
|
+
const session = await requireAuth();
|
|
27
|
+
const orgId = session.organizationId;
|
|
28
|
+
if (!orgId) return { success: false, error: 'No organization found' };
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
const scopes = await db.query.userAccessScopes.findMany({
|
|
32
|
+
where: eq(userAccessScopes.userId, session.id),
|
|
33
|
+
with: {
|
|
34
|
+
hospital: true,
|
|
35
|
+
department: true,
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const hospitalList = await db.query.hospitals.findMany({
|
|
40
|
+
where: eq(hospitals.organizationId, orgId),
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const departmentList = await db.query.departments.findMany({
|
|
44
|
+
where: eq(departments.organizationId, orgId),
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const userRecord = await db.query.users.findFirst({
|
|
48
|
+
where: eq(users.id, session.id),
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const userDept = scopes.find(s => s.departmentId)?.department || departmentList[0] || null;
|
|
52
|
+
const userHosp = scopes.find(s => s.hospitalId)?.hospital || hospitalList[0] || null;
|
|
53
|
+
|
|
54
|
+
const isElevatedUser = session.roles.some((r: string) =>
|
|
55
|
+
['SYS_ADMIN', 'BIOMED_MGR', 'BIOMED_ENG', 'BIOMED_TECH'].includes(r)
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
success: true,
|
|
60
|
+
data: {
|
|
61
|
+
organizationId: orgId,
|
|
62
|
+
defaultHospitalId: userHosp?.id || hospitalList[0]?.id || '',
|
|
63
|
+
defaultHospitalName: userHosp?.name || hospitalList[0]?.name || 'Assigned Hospital',
|
|
64
|
+
defaultDepartmentId: userDept?.id || departmentList[0]?.id || '',
|
|
65
|
+
defaultDepartmentName: userDept?.name || departmentList[0]?.name || 'Assigned Department',
|
|
66
|
+
isElevatedUser,
|
|
67
|
+
hospitals: hospitalList.map(h => ({ id: h.id, name: h.name })),
|
|
68
|
+
departments: departmentList.map(d => ({ id: d.id, name: d.name, hospitalId: d.hospitalId })),
|
|
69
|
+
userFullName: session.fullName,
|
|
70
|
+
userPhone: userRecord?.phone || null,
|
|
71
|
+
userJobTitle: userRecord?.jobTitle || null,
|
|
72
|
+
userExtension: userRecord?.employeeIdentifier || null,
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
} catch (error: any) {
|
|
76
|
+
return { success: false, error: error.message };
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ── Ticket Number Generation ────────────────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
function generateTicketNumber(): string {
|
|
83
|
+
const now = new Date();
|
|
84
|
+
const ym = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}`;
|
|
85
|
+
const seq = String(Math.floor(Math.random() * 99999)).padStart(5, '0');
|
|
86
|
+
return `TCK-${ym}-${seq}`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ── Create Ticket ───────────────────────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
export async function createTicket(input: unknown) {
|
|
92
|
+
const session = await requireAuth();
|
|
93
|
+
const validated = createTicketSchema.safeParse(input);
|
|
94
|
+
if (!validated.success) return { success: false, error: validated.error.message };
|
|
95
|
+
|
|
96
|
+
try {
|
|
97
|
+
// Build device snapshot if deviceId provided
|
|
98
|
+
let device: any = null;
|
|
99
|
+
let deviceSnapshot: any = null;
|
|
100
|
+
let locationSnapshot: any = null;
|
|
101
|
+
if (validated.data.deviceId) {
|
|
102
|
+
device = await db.query.devices.findFirst({
|
|
103
|
+
where: eq(devices.id, validated.data.deviceId),
|
|
104
|
+
with: { hospital: true, department: true, location: true, deviceCategory: true, manufacturer: true },
|
|
105
|
+
});
|
|
106
|
+
if (device) {
|
|
107
|
+
deviceSnapshot = {
|
|
108
|
+
name: device.name,
|
|
109
|
+
assetNumber: device.assetNumber,
|
|
110
|
+
serialNumber: device.serialNumber,
|
|
111
|
+
internalCode: device.internalCode,
|
|
112
|
+
currentStatusCode: device.currentStatusCode,
|
|
113
|
+
category: (device as any).deviceCategory?.name,
|
|
114
|
+
manufacturer: (device as any).manufacturer?.name,
|
|
115
|
+
};
|
|
116
|
+
locationSnapshot = {
|
|
117
|
+
hospital: (device as any).hospital?.name,
|
|
118
|
+
department: (device as any).department?.name,
|
|
119
|
+
location: (device as any).location?.name,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const result = await db.transaction(async (tx) => {
|
|
125
|
+
const ticketNumber = generateTicketNumber();
|
|
126
|
+
|
|
127
|
+
const [ticket] = await tx.insert(serviceTickets).values({
|
|
128
|
+
ticketNumber,
|
|
129
|
+
organizationId: validated.data.organizationId,
|
|
130
|
+
deviceId: validated.data.deviceId || null,
|
|
131
|
+
hospitalId: validated.data.hospitalId,
|
|
132
|
+
departmentId: validated.data.departmentId,
|
|
133
|
+
locationId: validated.data.locationId || null,
|
|
134
|
+
source: validated.data.source,
|
|
135
|
+
ticketType: validated.data.ticketType,
|
|
136
|
+
title: validated.data.title,
|
|
137
|
+
description: validated.data.description,
|
|
138
|
+
reportedImpact: validated.data.reportedImpact,
|
|
139
|
+
reportedProblemCategory: validated.data.reportedProblemCategory || null,
|
|
140
|
+
reportedLocationCorrection: validated.data.reportedLocationCorrection || null,
|
|
141
|
+
requesterContactName: validated.data.requesterContactName || null,
|
|
142
|
+
requesterContactPhone: validated.data.requesterContactPhone || null,
|
|
143
|
+
reportedByUserId: session.id,
|
|
144
|
+
statusCode: 'new',
|
|
145
|
+
priorityCode: validated.data.reportedImpact === 'patient_care_affected' ? 'p1_critical' : 'p3_normal',
|
|
146
|
+
deviceSnapshot,
|
|
147
|
+
locationSnapshot,
|
|
148
|
+
roundVisitId: validated.data.roundVisitId || null,
|
|
149
|
+
}).returning();
|
|
150
|
+
|
|
151
|
+
// Automatically update device status if issue is reported on operational equipment
|
|
152
|
+
if (validated.data.deviceId && device) {
|
|
153
|
+
let newDeviceStatus: string | null = null;
|
|
154
|
+
if (['patient_care_affected', 'device_not_usable'].includes(validated.data.reportedImpact)) {
|
|
155
|
+
newDeviceStatus = 'under_repair';
|
|
156
|
+
} else if (validated.data.reportedImpact === 'device_usable') {
|
|
157
|
+
newDeviceStatus = 'operational_with_limitations';
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (newDeviceStatus && ['operational', 'standby'].includes(device.currentStatusCode)) {
|
|
161
|
+
await tx.update(devices)
|
|
162
|
+
.set({
|
|
163
|
+
currentStatusCode: newDeviceStatus as any,
|
|
164
|
+
statusChangedAt: new Date(),
|
|
165
|
+
statusChangedByUserId: session.id,
|
|
166
|
+
updatedAt: new Date(),
|
|
167
|
+
})
|
|
168
|
+
.where(eq(devices.id, validated.data.deviceId));
|
|
169
|
+
|
|
170
|
+
await tx.insert(deviceStatusHistory).values({
|
|
171
|
+
organizationId: validated.data.organizationId,
|
|
172
|
+
deviceId: validated.data.deviceId,
|
|
173
|
+
previousStatusCode: device.currentStatusCode,
|
|
174
|
+
newStatusCode: newDeviceStatus,
|
|
175
|
+
reason: `Problem Ticket ${ticketNumber} reported: ${validated.data.title}`,
|
|
176
|
+
notes: `Reported impact: ${validated.data.reportedImpact}. ${validated.data.description.substring(0, 200)}`,
|
|
177
|
+
effectiveAt: new Date(),
|
|
178
|
+
changedByUserId: session.id,
|
|
179
|
+
relatedTicketId: ticket.id,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Create initial timeline event
|
|
185
|
+
await tx.insert(serviceTicketEvents).values({
|
|
186
|
+
serviceTicketId: ticket.id,
|
|
187
|
+
eventType: 'created',
|
|
188
|
+
newValueJsonb: { ticketNumber, source: validated.data.source, impact: validated.data.reportedImpact },
|
|
189
|
+
visibility: 'public',
|
|
190
|
+
actorUserId: session.id,
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
await createAuditLog(tx, {
|
|
194
|
+
action: 'CREATE', entityType: 'service_ticket', entityId: ticket.id,
|
|
195
|
+
actorId: session.id, details: { ticketNumber, deviceId: validated.data.deviceId, source: validated.data.source },
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
return { success: true, data: ticket };
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
// Notify all scoped engineers (fire-and-forget, outside transaction)
|
|
202
|
+
if (result.success && result.data) {
|
|
203
|
+
const ticketData = result.data;
|
|
204
|
+
notifyEngineersOnNewTicket({
|
|
205
|
+
ticketId: ticketData.id,
|
|
206
|
+
ticketNumber: ticketData.ticketNumber,
|
|
207
|
+
title: ticketData.title,
|
|
208
|
+
organizationId: validated.data.organizationId,
|
|
209
|
+
hospitalId: validated.data.hospitalId,
|
|
210
|
+
departmentId: validated.data.departmentId,
|
|
211
|
+
departmentName: device?.department?.name,
|
|
212
|
+
deviceName: device?.name,
|
|
213
|
+
reportedImpact: validated.data.reportedImpact,
|
|
214
|
+
priorityCode: ticketData.priorityCode,
|
|
215
|
+
reportedByUserId: session.id,
|
|
216
|
+
reportedByName: session.fullName,
|
|
217
|
+
}).catch((err) => console.error('[Ticket] Notification dispatch failed:', err));
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return result;
|
|
221
|
+
} catch (error: any) {
|
|
222
|
+
return { success: false, error: error.message };
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// ── Get Ticket By ID ────────────────────────────────────────────────────────────
|
|
227
|
+
|
|
228
|
+
export async function getTicketById(id: string) {
|
|
229
|
+
await requireAuth();
|
|
230
|
+
try {
|
|
231
|
+
const data = await db.query.serviceTickets.findFirst({
|
|
232
|
+
where: eq(serviceTickets.id, id),
|
|
233
|
+
with: {
|
|
234
|
+
device: true,
|
|
235
|
+
hospital: true,
|
|
236
|
+
department: true,
|
|
237
|
+
location: true,
|
|
238
|
+
reportedByUser: true,
|
|
239
|
+
triagedByUser: true,
|
|
240
|
+
assignedEngineer: true,
|
|
241
|
+
resolvedByUser: true,
|
|
242
|
+
closedByUser: true,
|
|
243
|
+
},
|
|
244
|
+
});
|
|
245
|
+
return { success: true, data };
|
|
246
|
+
} catch (error: any) {
|
|
247
|
+
return { success: false, error: error.message };
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// ── List Tickets ────────────────────────────────────────────────────────────────
|
|
252
|
+
|
|
253
|
+
export async function listTickets(filters?: {
|
|
254
|
+
search?: string;
|
|
255
|
+
organizationId?: string;
|
|
256
|
+
hospitalId?: string;
|
|
257
|
+
departmentId?: string;
|
|
258
|
+
statusCode?: string;
|
|
259
|
+
priorityCode?: string;
|
|
260
|
+
assignedEngineerUserId?: string;
|
|
261
|
+
reportedByUserId?: string;
|
|
262
|
+
deviceId?: string;
|
|
263
|
+
page?: number;
|
|
264
|
+
pageSize?: number;
|
|
265
|
+
}) {
|
|
266
|
+
await requireAuth();
|
|
267
|
+
try {
|
|
268
|
+
const conditions: any[] = [];
|
|
269
|
+
if (filters?.organizationId) conditions.push(eq(serviceTickets.organizationId, filters.organizationId));
|
|
270
|
+
if (filters?.hospitalId) conditions.push(eq(serviceTickets.hospitalId, filters.hospitalId));
|
|
271
|
+
if (filters?.departmentId) conditions.push(eq(serviceTickets.departmentId, filters.departmentId));
|
|
272
|
+
if (filters?.statusCode) conditions.push(eq(serviceTickets.statusCode, filters.statusCode as any));
|
|
273
|
+
if (filters?.priorityCode) conditions.push(eq(serviceTickets.priorityCode, filters.priorityCode as any));
|
|
274
|
+
if (filters?.assignedEngineerUserId) conditions.push(eq(serviceTickets.assignedEngineerUserId, filters.assignedEngineerUserId));
|
|
275
|
+
if (filters?.reportedByUserId) conditions.push(eq(serviceTickets.reportedByUserId, filters.reportedByUserId));
|
|
276
|
+
if (filters?.deviceId) conditions.push(eq(serviceTickets.deviceId, filters.deviceId));
|
|
277
|
+
if (filters?.search) {
|
|
278
|
+
conditions.push(
|
|
279
|
+
or(
|
|
280
|
+
ilike(serviceTickets.title, `%${filters.search}%`),
|
|
281
|
+
ilike(serviceTickets.ticketNumber, `%${filters.search}%`),
|
|
282
|
+
ilike(serviceTickets.description, `%${filters.search}%`),
|
|
283
|
+
)
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const page = filters?.page || 1;
|
|
288
|
+
const pageSize = filters?.pageSize || 25;
|
|
289
|
+
const whereClause = conditions.length > 0 ? and(...conditions) : undefined;
|
|
290
|
+
|
|
291
|
+
const data = await db.query.serviceTickets.findMany({
|
|
292
|
+
where: whereClause,
|
|
293
|
+
with: {
|
|
294
|
+
device: true,
|
|
295
|
+
hospital: true,
|
|
296
|
+
department: true,
|
|
297
|
+
reportedByUser: true,
|
|
298
|
+
assignedEngineer: true,
|
|
299
|
+
},
|
|
300
|
+
orderBy: [desc(serviceTickets.createdAt)],
|
|
301
|
+
limit: pageSize,
|
|
302
|
+
offset: (page - 1) * pageSize,
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
return { success: true, data };
|
|
306
|
+
} catch (error: any) {
|
|
307
|
+
return { success: false, error: error.message };
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// ── Triage Queue ────────────────────────────────────────────────────────────────
|
|
312
|
+
|
|
313
|
+
export async function getTriageQueue(filters?: {
|
|
314
|
+
organizationId?: string;
|
|
315
|
+
hospitalId?: string;
|
|
316
|
+
}) {
|
|
317
|
+
await requireAuth();
|
|
318
|
+
await requireRole('SYS_ADMIN', 'BIOMED_MGR', 'BIOMED_ENG');
|
|
319
|
+
try {
|
|
320
|
+
const conditions: any[] = [
|
|
321
|
+
or(
|
|
322
|
+
eq(serviceTickets.statusCode, 'new'),
|
|
323
|
+
eq(serviceTickets.statusCode, 'acknowledged'),
|
|
324
|
+
eq(serviceTickets.statusCode, 'in_triage'),
|
|
325
|
+
),
|
|
326
|
+
];
|
|
327
|
+
if (filters?.organizationId) conditions.push(eq(serviceTickets.organizationId, filters.organizationId));
|
|
328
|
+
if (filters?.hospitalId) conditions.push(eq(serviceTickets.hospitalId, filters.hospitalId));
|
|
329
|
+
|
|
330
|
+
const data = await db.query.serviceTickets.findMany({
|
|
331
|
+
where: and(...conditions),
|
|
332
|
+
with: {
|
|
333
|
+
device: true,
|
|
334
|
+
hospital: true,
|
|
335
|
+
department: true,
|
|
336
|
+
reportedByUser: true,
|
|
337
|
+
assignedEngineer: true,
|
|
338
|
+
},
|
|
339
|
+
orderBy: [desc(serviceTickets.priorityCode), desc(serviceTickets.createdAt)],
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
return { success: true, data };
|
|
343
|
+
} catch (error: any) {
|
|
344
|
+
return { success: false, error: error.message };
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// ── Triage Ticket ───────────────────────────────────────────────────────────────
|
|
349
|
+
|
|
350
|
+
export async function triageTicket(ticketId: string, input: unknown) {
|
|
351
|
+
const session = await requireAuth();
|
|
352
|
+
await requireRole('SYS_ADMIN', 'BIOMED_MGR', 'BIOMED_ENG');
|
|
353
|
+
const validated = triageTicketSchema.safeParse(input);
|
|
354
|
+
if (!validated.success) return { success: false, error: validated.error.message };
|
|
355
|
+
|
|
356
|
+
try {
|
|
357
|
+
const ticket = await db.query.serviceTickets.findFirst({ where: eq(serviceTickets.id, ticketId) });
|
|
358
|
+
if (!ticket) return { success: false, error: 'Ticket not found' };
|
|
359
|
+
|
|
360
|
+
return await db.transaction(async (tx) => {
|
|
361
|
+
// If an engineer is assigned, triage is complete → advance to in_progress
|
|
362
|
+
// Otherwise, mark as in_triage (awaiting assignment)
|
|
363
|
+
const newStatus = validated.data.assignedEngineerUserId ? 'in_progress' : 'in_triage';
|
|
364
|
+
|
|
365
|
+
const updateData: any = {
|
|
366
|
+
priorityCode: validated.data.priorityCode,
|
|
367
|
+
priorityReason: validated.data.priorityReason || null,
|
|
368
|
+
selectedMaintenanceType: validated.data.selectedMaintenanceType || null,
|
|
369
|
+
selectedTechnicalCategory: validated.data.selectedTechnicalCategory || null,
|
|
370
|
+
internalTriageNote: validated.data.internalTriageNote || null,
|
|
371
|
+
triagedByUserId: session.id,
|
|
372
|
+
triagedAt: new Date(),
|
|
373
|
+
statusCode: newStatus as any,
|
|
374
|
+
updatedAt: new Date(),
|
|
375
|
+
};
|
|
376
|
+
|
|
377
|
+
if (validated.data.assignedEngineerUserId) {
|
|
378
|
+
updateData.assignedEngineerUserId = validated.data.assignedEngineerUserId;
|
|
379
|
+
updateData.assignedAt = new Date();
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const [updated] = await tx.update(serviceTickets)
|
|
383
|
+
.set(updateData)
|
|
384
|
+
.where(eq(serviceTickets.id, ticketId))
|
|
385
|
+
.returning();
|
|
386
|
+
|
|
387
|
+
await tx.insert(serviceTicketEvents).values({
|
|
388
|
+
serviceTicketId: ticketId,
|
|
389
|
+
eventType: 'triaged',
|
|
390
|
+
previousValueJsonb: { statusCode: ticket.statusCode, priorityCode: ticket.priorityCode },
|
|
391
|
+
newValueJsonb: {
|
|
392
|
+
statusCode: newStatus,
|
|
393
|
+
priorityCode: validated.data.priorityCode,
|
|
394
|
+
maintenanceType: validated.data.selectedMaintenanceType,
|
|
395
|
+
assignedEngineer: validated.data.assignedEngineerUserId || null,
|
|
396
|
+
},
|
|
397
|
+
visibility: 'internal',
|
|
398
|
+
actorUserId: session.id,
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
await createAuditLog(tx, {
|
|
402
|
+
action: 'UPDATE', entityType: 'service_ticket', entityId: ticketId,
|
|
403
|
+
actorId: session.id, details: { action: 'triage', newStatus, ...validated.data },
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
return { success: true, data: updated };
|
|
407
|
+
});
|
|
408
|
+
} catch (error: any) {
|
|
409
|
+
return { success: false, error: error.message };
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// ── Assign Ticket ───────────────────────────────────────────────────────────────
|
|
414
|
+
|
|
415
|
+
export async function assignTicket(ticketId: string, input: unknown) {
|
|
416
|
+
const session = await requireAuth();
|
|
417
|
+
await requireRole('SYS_ADMIN', 'BIOMED_MGR', 'BIOMED_ENG');
|
|
418
|
+
const validated = assignTicketSchema.safeParse(input);
|
|
419
|
+
if (!validated.success) return { success: false, error: validated.error.message };
|
|
420
|
+
|
|
421
|
+
try {
|
|
422
|
+
const ticket = await db.query.serviceTickets.findFirst({ where: eq(serviceTickets.id, ticketId) });
|
|
423
|
+
if (!ticket) return { success: false, error: 'Ticket not found' };
|
|
424
|
+
|
|
425
|
+
const result = await db.transaction(async (tx) => {
|
|
426
|
+
const [updated] = await tx.update(serviceTickets)
|
|
427
|
+
.set({
|
|
428
|
+
assignedEngineerUserId: validated.data.assignedEngineerUserId,
|
|
429
|
+
assignedTeamId: validated.data.assignedTeamId || null,
|
|
430
|
+
assignedAt: new Date(),
|
|
431
|
+
statusCode: 'in_progress',
|
|
432
|
+
updatedAt: new Date(),
|
|
433
|
+
})
|
|
434
|
+
.where(eq(serviceTickets.id, ticketId))
|
|
435
|
+
.returning();
|
|
436
|
+
|
|
437
|
+
await tx.insert(serviceTicketEvents).values({
|
|
438
|
+
serviceTicketId: ticketId,
|
|
439
|
+
eventType: ticket.assignedEngineerUserId ? 'reassigned' : 'assigned',
|
|
440
|
+
previousValueJsonb: { assignedEngineerUserId: ticket.assignedEngineerUserId },
|
|
441
|
+
newValueJsonb: { assignedEngineerUserId: validated.data.assignedEngineerUserId },
|
|
442
|
+
reason: validated.data.handoffNote,
|
|
443
|
+
visibility: 'internal',
|
|
444
|
+
actorUserId: session.id,
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
await createAuditLog(tx, {
|
|
448
|
+
action: 'UPDATE', entityType: 'service_ticket', entityId: ticketId,
|
|
449
|
+
actorId: session.id, details: { action: 'assign', ...validated.data },
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
return { success: true, data: updated };
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
// Notify the assigned engineer (fire-and-forget)
|
|
456
|
+
if (result.success && validated.data.assignedEngineerUserId) {
|
|
457
|
+
notifyEngineerOnAssign({
|
|
458
|
+
ticketId,
|
|
459
|
+
ticketNumber: ticket.ticketNumber,
|
|
460
|
+
title: ticket.title,
|
|
461
|
+
organizationId: ticket.organizationId,
|
|
462
|
+
assignedEngineerUserId: validated.data.assignedEngineerUserId,
|
|
463
|
+
assignerName: session.fullName || session.email || 'System',
|
|
464
|
+
}).catch((err) => console.error('[Ticket] Engineer notification failed:', err));
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// Also notify reporter that ticket is assigned / in progress
|
|
468
|
+
if (result.success && ticket.reportedByUserId) {
|
|
469
|
+
notifyReporterOnStatusChange({
|
|
470
|
+
ticketId,
|
|
471
|
+
ticketNumber: ticket.ticketNumber,
|
|
472
|
+
title: ticket.title,
|
|
473
|
+
organizationId: ticket.organizationId,
|
|
474
|
+
reporterUserId: ticket.reportedByUserId,
|
|
475
|
+
newStatus: 'in_progress',
|
|
476
|
+
actorName: session.fullName || session.email || 'System',
|
|
477
|
+
}).catch((err) => console.error('[Ticket] Reporter assignment notification failed:', err));
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
return result;
|
|
481
|
+
} catch (error: any) {
|
|
482
|
+
return { success: false, error: error.message };
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// ── Accept Ticket (with Electronic Signature) ───────────────────────────────────
|
|
487
|
+
|
|
488
|
+
export async function acceptTicket(ticketId: string, input?: unknown) {
|
|
489
|
+
const session = await requireAuth();
|
|
490
|
+
await requireRole('SYS_ADMIN', 'BIOMED_MGR', 'BIOMED_ENG', 'BIOMED_TECH');
|
|
491
|
+
try {
|
|
492
|
+
const ticket = await db.query.serviceTickets.findFirst({ where: eq(serviceTickets.id, ticketId) });
|
|
493
|
+
if (!ticket) return { success: false, error: 'Ticket not found' };
|
|
494
|
+
if (['resolved', 'closed', 'cancelled'].includes(ticket.statusCode)) {
|
|
495
|
+
return { success: false, error: `Cannot accept ticket in '${ticket.statusCode}' status` };
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
const result = await db.transaction(async (tx) => {
|
|
499
|
+
const [updated] = await tx.update(serviceTickets)
|
|
500
|
+
.set({
|
|
501
|
+
statusCode: 'in_progress',
|
|
502
|
+
assignedEngineerUserId: ticket.assignedEngineerUserId || session.id,
|
|
503
|
+
acceptedAt: new Date(),
|
|
504
|
+
updatedAt: new Date(),
|
|
505
|
+
})
|
|
506
|
+
.where(eq(serviceTickets.id, ticketId))
|
|
507
|
+
.returning();
|
|
508
|
+
|
|
509
|
+
// If device linked, update status to under_maintenance if operational
|
|
510
|
+
if (ticket.deviceId) {
|
|
511
|
+
const dev = await tx.query.devices.findFirst({ where: eq(devices.id, ticket.deviceId) });
|
|
512
|
+
if (dev && dev.currentStatusCode === 'operational') {
|
|
513
|
+
await tx.update(devices)
|
|
514
|
+
.set({
|
|
515
|
+
currentStatusCode: 'under_maintenance',
|
|
516
|
+
statusChangedAt: new Date(),
|
|
517
|
+
statusChangedByUserId: session.id,
|
|
518
|
+
updatedAt: new Date(),
|
|
519
|
+
})
|
|
520
|
+
.where(eq(devices.id, ticket.deviceId));
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
await tx.insert(serviceTicketEvents).values({
|
|
525
|
+
serviceTicketId: ticketId,
|
|
526
|
+
eventType: 'accepted',
|
|
527
|
+
previousValueJsonb: { statusCode: ticket.statusCode },
|
|
528
|
+
newValueJsonb: {
|
|
529
|
+
statusCode: 'in_progress',
|
|
530
|
+
assignedEngineerUserId: updated.assignedEngineerUserId,
|
|
531
|
+
},
|
|
532
|
+
reason: 'Engineer accepted ticket and started work',
|
|
533
|
+
visibility: 'public',
|
|
534
|
+
actorUserId: session.id,
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
await createAuditLog(tx, {
|
|
538
|
+
action: 'UPDATE', entityType: 'service_ticket', entityId: ticketId,
|
|
539
|
+
actorId: session.id, details: { action: 'accept', newStatus: 'in_progress' },
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
return { success: true, data: updated };
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
// Notify reporter that their ticket has been accepted (fire-and-forget)
|
|
546
|
+
if (result.success && ticket.reportedByUserId) {
|
|
547
|
+
notifyReporterOnAccept({
|
|
548
|
+
ticketId,
|
|
549
|
+
ticketNumber: ticket.ticketNumber,
|
|
550
|
+
title: ticket.title,
|
|
551
|
+
organizationId: ticket.organizationId,
|
|
552
|
+
reporterUserId: ticket.reportedByUserId,
|
|
553
|
+
engineerName: session.fullName || session.email || 'Biomedical Engineer',
|
|
554
|
+
engineerUserId: session.id,
|
|
555
|
+
}).catch((err) => console.error('[Ticket] Reporter notification failed:', err));
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
return result;
|
|
559
|
+
} catch (error: any) {
|
|
560
|
+
return { success: false, error: error.message };
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// ── Resolve Ticket ──────────────────────────────────────────────────────────────
|
|
565
|
+
|
|
566
|
+
export async function resolveTicket(ticketId: string, input: unknown) {
|
|
567
|
+
const session = await requireAuth();
|
|
568
|
+
await requireRole('SYS_ADMIN', 'BIOMED_MGR', 'BIOMED_ENG');
|
|
569
|
+
const validated = resolveTicketSchema.safeParse(input);
|
|
570
|
+
if (!validated.success) return { success: false, error: validated.error.message };
|
|
571
|
+
|
|
572
|
+
try {
|
|
573
|
+
const ticket = await db.query.serviceTickets.findFirst({ where: eq(serviceTickets.id, ticketId) });
|
|
574
|
+
if (!ticket) return { success: false, error: 'Ticket not found' };
|
|
575
|
+
if (!['in_progress', 'in_triage', 'waiting_parts_vendor'].includes(ticket.statusCode)) {
|
|
576
|
+
return { success: false, error: `Cannot resolve ticket in '${ticket.statusCode}' status` };
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// Validate that finalDeviceStatusCode represents a legitimate resolution outcome
|
|
580
|
+
if (!RESOLUTION_DEVICE_STATUSES.includes(validated.data.finalDeviceStatusCode as any)) {
|
|
581
|
+
return {
|
|
582
|
+
success: false,
|
|
583
|
+
error: `Cannot resolve ticket with device status '${validated.data.finalDeviceStatusCode}'. A ticket can only be resolved when work is complete or device is retired. Use 'Update Status' to track ongoing repair work.`,
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// Verify signature password if provided
|
|
588
|
+
if (validated.data.signaturePassword) {
|
|
589
|
+
const authResult = await verifySignatureAuth(session.id, validated.data.signaturePassword);
|
|
590
|
+
if (!authResult.success) {
|
|
591
|
+
return { success: false, error: authResult.error };
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
const result = await db.transaction(async (tx) => {
|
|
596
|
+
const [updated] = await tx.update(serviceTickets)
|
|
597
|
+
.set({
|
|
598
|
+
statusCode: 'resolved',
|
|
599
|
+
resolutionSummary: validated.data.resolutionSummary,
|
|
600
|
+
finalDeviceStatusCode: validated.data.finalDeviceStatusCode as any,
|
|
601
|
+
waitingReason: null,
|
|
602
|
+
waitingDependencyType: null,
|
|
603
|
+
resolvedAt: new Date(),
|
|
604
|
+
resolvedByUserId: session.id,
|
|
605
|
+
updatedAt: new Date(),
|
|
606
|
+
})
|
|
607
|
+
.where(eq(serviceTickets.id, ticketId))
|
|
608
|
+
.returning();
|
|
609
|
+
|
|
610
|
+
// Update device status and audit history if device is linked
|
|
611
|
+
if (ticket.deviceId) {
|
|
612
|
+
const currentDev = await tx.query.devices.findFirst({ where: eq(devices.id, ticket.deviceId) });
|
|
613
|
+
const prevStatusCode = currentDev?.currentStatusCode || 'under_repair';
|
|
614
|
+
|
|
615
|
+
const limitationsNote = validated.data.finalDeviceStatusCode === 'operational_with_limitations'
|
|
616
|
+
? (validated.data.limitationsNote || validated.data.resolutionSummary)
|
|
617
|
+
: null;
|
|
618
|
+
|
|
619
|
+
await tx.update(devices)
|
|
620
|
+
.set({
|
|
621
|
+
currentStatusCode: validated.data.finalDeviceStatusCode as any,
|
|
622
|
+
statusLimitationsNote: limitationsNote,
|
|
623
|
+
statusChangedAt: new Date(),
|
|
624
|
+
statusChangedByUserId: session.id,
|
|
625
|
+
updatedAt: new Date(),
|
|
626
|
+
})
|
|
627
|
+
.where(eq(devices.id, ticket.deviceId));
|
|
628
|
+
|
|
629
|
+
await tx.insert(deviceStatusHistory).values({
|
|
630
|
+
organizationId: ticket.organizationId,
|
|
631
|
+
deviceId: ticket.deviceId,
|
|
632
|
+
previousStatusCode: prevStatusCode,
|
|
633
|
+
newStatusCode: validated.data.finalDeviceStatusCode,
|
|
634
|
+
reason: `Ticket ${ticket.ticketNumber} resolved: ${validated.data.resolutionSummary.substring(0, 100)}`,
|
|
635
|
+
notes: limitationsNote ? `Limitations: ${limitationsNote} | Summary: ${validated.data.resolutionSummary}` : validated.data.resolutionSummary,
|
|
636
|
+
effectiveAt: new Date(),
|
|
637
|
+
changedByUserId: session.id,
|
|
638
|
+
relatedTicketId: ticketId,
|
|
639
|
+
});
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
// If signed with password: create record version + electronic signature + signature event
|
|
643
|
+
let signatureRecord: any = null;
|
|
644
|
+
if (validated.data.signaturePassword) {
|
|
645
|
+
const prevVersions = await tx.query.recordVersions.findMany({
|
|
646
|
+
where: and(
|
|
647
|
+
eq(recordVersions.entityType, 'ticket_resolution'),
|
|
648
|
+
eq(recordVersions.entityId, ticketId),
|
|
649
|
+
),
|
|
650
|
+
});
|
|
651
|
+
const versionNumber = prevVersions.length + 1;
|
|
652
|
+
|
|
653
|
+
const snapshotData = {
|
|
654
|
+
ticketId,
|
|
655
|
+
ticketNumber: ticket.ticketNumber,
|
|
656
|
+
title: ticket.title,
|
|
657
|
+
description: ticket.description,
|
|
658
|
+
deviceId: ticket.deviceId,
|
|
659
|
+
action: 'resolve',
|
|
660
|
+
resolutionSummary: validated.data.resolutionSummary,
|
|
661
|
+
finalDeviceStatusCode: validated.data.finalDeviceStatusCode,
|
|
662
|
+
resolvedByUserId: session.id,
|
|
663
|
+
resolvedAt: new Date().toISOString(),
|
|
664
|
+
};
|
|
665
|
+
const canonicalJson = buildCanonicalSnapshot(snapshotData as any);
|
|
666
|
+
const contentHash = computeSHA256(canonicalJson);
|
|
667
|
+
|
|
668
|
+
const [version] = await tx.insert(recordVersions).values({
|
|
669
|
+
organizationId: ticket.organizationId,
|
|
670
|
+
entityType: 'ticket_resolution',
|
|
671
|
+
entityId: ticketId,
|
|
672
|
+
versionNumber,
|
|
673
|
+
canonicalSnapshotJsonb: snapshotData,
|
|
674
|
+
contentHashSha256: contentHash,
|
|
675
|
+
createdByUserId: session.id,
|
|
676
|
+
}).returning();
|
|
677
|
+
|
|
678
|
+
const [sig] = await tx.insert(electronicSignatures).values({
|
|
679
|
+
organizationId: ticket.organizationId,
|
|
680
|
+
recordVersionId: version.id,
|
|
681
|
+
entityType: 'ticket_resolution',
|
|
682
|
+
entityId: ticketId,
|
|
683
|
+
signaturePurpose: 'resolve',
|
|
684
|
+
signerUserId: session.id,
|
|
685
|
+
signerNameSnapshot: session.fullName || session.email || 'Biomedical Engineer',
|
|
686
|
+
signerRoleSnapshot: session.roles?.join(', ') || 'BIOMED_ENG',
|
|
687
|
+
attestationTextVersion: getAttestationText('resolve'),
|
|
688
|
+
authMethod: 'password_reauth',
|
|
689
|
+
signedAt: new Date(),
|
|
690
|
+
signedContentHashSha256: contentHash,
|
|
691
|
+
comments: validated.data.signatureComments || null,
|
|
692
|
+
}).returning();
|
|
693
|
+
signatureRecord = sig;
|
|
694
|
+
|
|
695
|
+
await createSignatureEvent(tx, {
|
|
696
|
+
signatureId: sig.id,
|
|
697
|
+
eventType: 'created',
|
|
698
|
+
actorUserId: session.id,
|
|
699
|
+
organizationId: ticket.organizationId,
|
|
700
|
+
newStatus: 'active',
|
|
701
|
+
});
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
await tx.insert(serviceTicketEvents).values({
|
|
705
|
+
serviceTicketId: ticketId,
|
|
706
|
+
eventType: 'resolved',
|
|
707
|
+
previousValueJsonb: { statusCode: ticket.statusCode },
|
|
708
|
+
newValueJsonb: {
|
|
709
|
+
statusCode: 'resolved',
|
|
710
|
+
finalDeviceStatus: validated.data.finalDeviceStatusCode,
|
|
711
|
+
signatureId: signatureRecord?.id || null,
|
|
712
|
+
contentHash: signatureRecord?.signedContentHashSha256 || null,
|
|
713
|
+
},
|
|
714
|
+
reason: validated.data.resolutionSummary,
|
|
715
|
+
visibility: 'public',
|
|
716
|
+
actorUserId: session.id,
|
|
717
|
+
});
|
|
718
|
+
|
|
719
|
+
await createAuditLog(tx, {
|
|
720
|
+
action: 'UPDATE', entityType: 'service_ticket', entityId: ticketId,
|
|
721
|
+
actorId: session.id, details: { action: 'resolve', signed: !!signatureRecord, ...validated.data },
|
|
722
|
+
});
|
|
723
|
+
|
|
724
|
+
return { success: true, data: updated, signature: signatureRecord };
|
|
725
|
+
});
|
|
726
|
+
|
|
727
|
+
// Notify reporter that their ticket has been resolved (fire-and-forget)
|
|
728
|
+
if (result.success && ticket.reportedByUserId) {
|
|
729
|
+
notifyReporterOnStatusChange({
|
|
730
|
+
ticketId,
|
|
731
|
+
ticketNumber: ticket.ticketNumber,
|
|
732
|
+
title: ticket.title,
|
|
733
|
+
organizationId: ticket.organizationId,
|
|
734
|
+
reporterUserId: ticket.reportedByUserId,
|
|
735
|
+
newStatus: 'resolved',
|
|
736
|
+
actorName: session.fullName || session.email || 'Biomedical Engineer',
|
|
737
|
+
}).catch((err) => console.error('[Ticket] Reporter notification failed:', err));
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
return result;
|
|
741
|
+
} catch (error: any) {
|
|
742
|
+
return { success: false, error: error.message };
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
// ── Close Ticket ────────────────────────────────────────────────────────────────
|
|
747
|
+
|
|
748
|
+
export async function closeTicket(ticketId: string, input: unknown) {
|
|
749
|
+
const session = await requireAuth();
|
|
750
|
+
const validated = closeTicketSchema.safeParse(input);
|
|
751
|
+
if (!validated.success) return { success: false, error: validated.error.message };
|
|
752
|
+
|
|
753
|
+
try {
|
|
754
|
+
const ticket = await db.query.serviceTickets.findFirst({ where: eq(serviceTickets.id, ticketId) });
|
|
755
|
+
if (!ticket) return { success: false, error: 'Ticket not found' };
|
|
756
|
+
if (ticket.statusCode !== 'resolved') {
|
|
757
|
+
return { success: false, error: 'Ticket must be resolved before closing' };
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
// Verify signature password if provided
|
|
761
|
+
if (validated.data.signaturePassword) {
|
|
762
|
+
const authResult = await verifySignatureAuth(session.id, validated.data.signaturePassword);
|
|
763
|
+
if (!authResult.success) {
|
|
764
|
+
return { success: false, error: authResult.error };
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
const result = await db.transaction(async (tx) => {
|
|
769
|
+
const [updated] = await tx.update(serviceTickets)
|
|
770
|
+
.set({
|
|
771
|
+
statusCode: 'closed',
|
|
772
|
+
closedAt: new Date(),
|
|
773
|
+
closedByUserId: session.id,
|
|
774
|
+
closureReason: validated.data.closureReason || null,
|
|
775
|
+
updatedAt: new Date(),
|
|
776
|
+
})
|
|
777
|
+
.where(eq(serviceTickets.id, ticketId))
|
|
778
|
+
.returning();
|
|
779
|
+
|
|
780
|
+
let signatureRecord: any = null;
|
|
781
|
+
if (validated.data.signaturePassword) {
|
|
782
|
+
const prevVersions = await tx.query.recordVersions.findMany({
|
|
783
|
+
where: and(
|
|
784
|
+
eq(recordVersions.entityType, 'ticket_resolution'),
|
|
785
|
+
eq(recordVersions.entityId, ticketId),
|
|
786
|
+
),
|
|
787
|
+
});
|
|
788
|
+
const versionNumber = prevVersions.length + 1;
|
|
789
|
+
|
|
790
|
+
const snapshotData = {
|
|
791
|
+
ticketId,
|
|
792
|
+
ticketNumber: ticket.ticketNumber,
|
|
793
|
+
title: ticket.title,
|
|
794
|
+
description: ticket.description,
|
|
795
|
+
action: 'close',
|
|
796
|
+
closureReason: validated.data.closureReason || null,
|
|
797
|
+
closedByUserId: session.id,
|
|
798
|
+
closedAt: new Date().toISOString(),
|
|
799
|
+
};
|
|
800
|
+
const canonicalJson = buildCanonicalSnapshot(snapshotData as any);
|
|
801
|
+
const contentHash = computeSHA256(canonicalJson);
|
|
802
|
+
|
|
803
|
+
const [version] = await tx.insert(recordVersions).values({
|
|
804
|
+
organizationId: ticket.organizationId,
|
|
805
|
+
entityType: 'ticket_resolution',
|
|
806
|
+
entityId: ticketId,
|
|
807
|
+
versionNumber,
|
|
808
|
+
canonicalSnapshotJsonb: snapshotData,
|
|
809
|
+
contentHashSha256: contentHash,
|
|
810
|
+
createdByUserId: session.id,
|
|
811
|
+
}).returning();
|
|
812
|
+
|
|
813
|
+
const [sig] = await tx.insert(electronicSignatures).values({
|
|
814
|
+
organizationId: ticket.organizationId,
|
|
815
|
+
recordVersionId: version.id,
|
|
816
|
+
entityType: 'ticket_resolution',
|
|
817
|
+
entityId: ticketId,
|
|
818
|
+
signaturePurpose: 'close',
|
|
819
|
+
signerUserId: session.id,
|
|
820
|
+
signerNameSnapshot: session.fullName || session.email || 'System User',
|
|
821
|
+
signerRoleSnapshot: session.roles?.join(', ') || 'STAFF',
|
|
822
|
+
attestationTextVersion: getAttestationText('close'),
|
|
823
|
+
authMethod: 'password_reauth',
|
|
824
|
+
signedAt: new Date(),
|
|
825
|
+
signedContentHashSha256: contentHash,
|
|
826
|
+
comments: validated.data.signatureComments || null,
|
|
827
|
+
}).returning();
|
|
828
|
+
signatureRecord = sig;
|
|
829
|
+
|
|
830
|
+
await createSignatureEvent(tx, {
|
|
831
|
+
signatureId: sig.id,
|
|
832
|
+
eventType: 'created',
|
|
833
|
+
actorUserId: session.id,
|
|
834
|
+
organizationId: ticket.organizationId,
|
|
835
|
+
newStatus: 'active',
|
|
836
|
+
});
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
await tx.insert(serviceTicketEvents).values({
|
|
840
|
+
serviceTicketId: ticketId,
|
|
841
|
+
eventType: 'closed',
|
|
842
|
+
previousValueJsonb: { statusCode: 'resolved' },
|
|
843
|
+
newValueJsonb: {
|
|
844
|
+
statusCode: 'closed',
|
|
845
|
+
signatureId: signatureRecord?.id || null,
|
|
846
|
+
contentHash: signatureRecord?.signedContentHashSha256 || null,
|
|
847
|
+
},
|
|
848
|
+
reason: validated.data.closureReason,
|
|
849
|
+
visibility: 'public',
|
|
850
|
+
actorUserId: session.id,
|
|
851
|
+
});
|
|
852
|
+
|
|
853
|
+
await createAuditLog(tx, {
|
|
854
|
+
action: 'UPDATE', entityType: 'service_ticket', entityId: ticketId,
|
|
855
|
+
actorId: session.id, details: { action: 'close', signed: !!signatureRecord },
|
|
856
|
+
});
|
|
857
|
+
|
|
858
|
+
return { success: true, data: updated, signature: signatureRecord };
|
|
859
|
+
});
|
|
860
|
+
|
|
861
|
+
// Notify reporter that their ticket has been closed (fire-and-forget)
|
|
862
|
+
if (result.success && ticket.reportedByUserId) {
|
|
863
|
+
notifyReporterOnStatusChange({
|
|
864
|
+
ticketId,
|
|
865
|
+
ticketNumber: ticket.ticketNumber,
|
|
866
|
+
title: ticket.title,
|
|
867
|
+
organizationId: ticket.organizationId,
|
|
868
|
+
reporterUserId: ticket.reportedByUserId,
|
|
869
|
+
newStatus: 'closed',
|
|
870
|
+
actorName: session.fullName || session.email || 'System User',
|
|
871
|
+
}).catch((err) => console.error('[Ticket] Reporter notification failed:', err));
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
return result;
|
|
875
|
+
} catch (error: any) {
|
|
876
|
+
return { success: false, error: error.message };
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
// ── Cancel Ticket ───────────────────────────────────────────────────────────────
|
|
881
|
+
|
|
882
|
+
export async function cancelTicket(ticketId: string, input: unknown) {
|
|
883
|
+
const session = await requireAuth();
|
|
884
|
+
const validated = cancelTicketSchema.safeParse(input);
|
|
885
|
+
if (!validated.success) return { success: false, error: validated.error.message };
|
|
886
|
+
|
|
887
|
+
try {
|
|
888
|
+
const ticket = await db.query.serviceTickets.findFirst({ where: eq(serviceTickets.id, ticketId) });
|
|
889
|
+
if (!ticket) return { success: false, error: 'Ticket not found' };
|
|
890
|
+
const cancellableStatuses = ['new', 'acknowledged', 'in_triage', 'waiting_requester'];
|
|
891
|
+
if (!cancellableStatuses.includes(ticket.statusCode)) {
|
|
892
|
+
return { success: false, error: `Cannot cancel ticket in '${ticket.statusCode}' status` };
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
return await db.transaction(async (tx) => {
|
|
896
|
+
const [updated] = await tx.update(serviceTickets)
|
|
897
|
+
.set({
|
|
898
|
+
statusCode: 'cancelled',
|
|
899
|
+
cancelledAt: new Date(),
|
|
900
|
+
cancelledByUserId: session.id,
|
|
901
|
+
cancellationReason: validated.data.cancellationReason,
|
|
902
|
+
updatedAt: new Date(),
|
|
903
|
+
})
|
|
904
|
+
.where(eq(serviceTickets.id, ticketId))
|
|
905
|
+
.returning();
|
|
906
|
+
|
|
907
|
+
await tx.insert(serviceTicketEvents).values({
|
|
908
|
+
serviceTicketId: ticketId,
|
|
909
|
+
eventType: 'cancelled',
|
|
910
|
+
previousValueJsonb: { statusCode: ticket.statusCode },
|
|
911
|
+
newValueJsonb: { statusCode: 'cancelled' },
|
|
912
|
+
reason: validated.data.cancellationReason,
|
|
913
|
+
visibility: 'public',
|
|
914
|
+
actorUserId: session.id,
|
|
915
|
+
});
|
|
916
|
+
|
|
917
|
+
await createAuditLog(tx, {
|
|
918
|
+
action: 'DEACTIVATE', entityType: 'service_ticket', entityId: ticketId,
|
|
919
|
+
actorId: session.id, details: { action: 'cancel', reason: validated.data.cancellationReason },
|
|
920
|
+
});
|
|
921
|
+
|
|
922
|
+
return { success: true, data: updated };
|
|
923
|
+
});
|
|
924
|
+
} catch (error: any) {
|
|
925
|
+
return { success: false, error: error.message };
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
// ── Reopen Ticket ───────────────────────────────────────────────────────────────
|
|
930
|
+
|
|
931
|
+
export async function reopenTicket(ticketId: string, input: unknown) {
|
|
932
|
+
const session = await requireAuth();
|
|
933
|
+
const validated = reopenTicketSchema.safeParse(input);
|
|
934
|
+
if (!validated.success) return { success: false, error: validated.error.message };
|
|
935
|
+
|
|
936
|
+
try {
|
|
937
|
+
const ticket = await db.query.serviceTickets.findFirst({ where: eq(serviceTickets.id, ticketId) });
|
|
938
|
+
if (!ticket) return { success: false, error: 'Ticket not found' };
|
|
939
|
+
if (!['resolved', 'closed', 'cancelled'].includes(ticket.statusCode)) {
|
|
940
|
+
return { success: false, error: `Cannot reopen ticket in '${ticket.statusCode}' status` };
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
const result = await db.transaction(async (tx) => {
|
|
944
|
+
// Determine next status: if assigned to an engineer, resume work in_progress; otherwise back to in_triage
|
|
945
|
+
const nextStatus = ticket.assignedEngineerUserId ? 'in_progress' : 'in_triage';
|
|
946
|
+
|
|
947
|
+
const [updated] = await tx.update(serviceTickets)
|
|
948
|
+
.set({
|
|
949
|
+
statusCode: nextStatus as any,
|
|
950
|
+
reopenedAt: new Date(),
|
|
951
|
+
reopenedByUserId: session.id,
|
|
952
|
+
reopenReason: validated.data.reopenReason,
|
|
953
|
+
resolvedAt: null,
|
|
954
|
+
resolvedByUserId: null,
|
|
955
|
+
resolutionSummary: null,
|
|
956
|
+
finalDeviceStatusCode: null,
|
|
957
|
+
closedAt: null,
|
|
958
|
+
closedByUserId: null,
|
|
959
|
+
closureReason: null,
|
|
960
|
+
cancelledAt: null,
|
|
961
|
+
cancelledByUserId: null,
|
|
962
|
+
cancellationReason: null,
|
|
963
|
+
updatedAt: new Date(),
|
|
964
|
+
})
|
|
965
|
+
.where(eq(serviceTickets.id, ticketId))
|
|
966
|
+
.returning();
|
|
967
|
+
|
|
968
|
+
// If linked to a device, restore device to under_repair and audit
|
|
969
|
+
if (ticket.deviceId) {
|
|
970
|
+
const currentDev = await tx.query.devices.findFirst({ where: eq(devices.id, ticket.deviceId) });
|
|
971
|
+
const prevStatusCode = currentDev?.currentStatusCode || 'operational';
|
|
972
|
+
|
|
973
|
+
await tx.update(devices)
|
|
974
|
+
.set({
|
|
975
|
+
currentStatusCode: 'under_repair',
|
|
976
|
+
statusChangedAt: new Date(),
|
|
977
|
+
statusChangedByUserId: session.id,
|
|
978
|
+
updatedAt: new Date(),
|
|
979
|
+
})
|
|
980
|
+
.where(eq(devices.id, ticket.deviceId));
|
|
981
|
+
|
|
982
|
+
await tx.insert(deviceStatusHistory).values({
|
|
983
|
+
organizationId: ticket.organizationId,
|
|
984
|
+
deviceId: ticket.deviceId,
|
|
985
|
+
previousStatusCode: prevStatusCode,
|
|
986
|
+
newStatusCode: 'under_repair',
|
|
987
|
+
reason: `Ticket ${ticket.ticketNumber} reopened: ${validated.data.reopenReason}`,
|
|
988
|
+
notes: `Reopened by ${session.fullName || session.email}. Resuming repair work.`,
|
|
989
|
+
effectiveAt: new Date(),
|
|
990
|
+
changedByUserId: session.id,
|
|
991
|
+
relatedTicketId: ticketId,
|
|
992
|
+
});
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
await tx.insert(serviceTicketEvents).values({
|
|
996
|
+
serviceTicketId: ticketId,
|
|
997
|
+
eventType: 'reopened',
|
|
998
|
+
previousValueJsonb: { statusCode: ticket.statusCode },
|
|
999
|
+
newValueJsonb: { statusCode: nextStatus },
|
|
1000
|
+
reason: validated.data.reopenReason,
|
|
1001
|
+
visibility: 'public',
|
|
1002
|
+
actorUserId: session.id,
|
|
1003
|
+
});
|
|
1004
|
+
|
|
1005
|
+
await createAuditLog(tx, {
|
|
1006
|
+
action: 'UPDATE', entityType: 'service_ticket', entityId: ticketId,
|
|
1007
|
+
actorId: session.id, details: { action: 'reopen', nextStatus, reason: validated.data.reopenReason },
|
|
1008
|
+
});
|
|
1009
|
+
|
|
1010
|
+
return { success: true, data: updated };
|
|
1011
|
+
});
|
|
1012
|
+
|
|
1013
|
+
// Notify reporter and assigned engineer about reopening (fire-and-forget)
|
|
1014
|
+
if (result.success && ticket.reportedByUserId) {
|
|
1015
|
+
notifyReporterOnStatusChange({
|
|
1016
|
+
ticketId,
|
|
1017
|
+
ticketNumber: ticket.ticketNumber,
|
|
1018
|
+
title: ticket.title,
|
|
1019
|
+
organizationId: ticket.organizationId,
|
|
1020
|
+
reporterUserId: ticket.reportedByUserId,
|
|
1021
|
+
newStatus: 'in_progress',
|
|
1022
|
+
actorName: session.fullName || session.email || 'System User',
|
|
1023
|
+
}).catch((err) => console.error('[Ticket] Reporter notification failed:', err));
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
return result;
|
|
1027
|
+
} catch (error: any) {
|
|
1028
|
+
return { success: false, error: error.message };
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
// ── Update Ticket Device Status (Interim Progress) ──────────────────────────────
|
|
1033
|
+
|
|
1034
|
+
export async function updateTicketDeviceStatus(ticketId: string, input: unknown) {
|
|
1035
|
+
const session = await requireAuth();
|
|
1036
|
+
await requireRole('SYS_ADMIN', 'BIOMED_MGR', 'BIOMED_ENG', 'BIOMED_TECH');
|
|
1037
|
+
const validated = updateTicketDeviceStatusSchema.safeParse(input);
|
|
1038
|
+
if (!validated.success) return { success: false, error: validated.error.message };
|
|
1039
|
+
|
|
1040
|
+
try {
|
|
1041
|
+
const ticket = await db.query.serviceTickets.findFirst({ where: eq(serviceTickets.id, ticketId) });
|
|
1042
|
+
if (!ticket) return { success: false, error: 'Ticket not found' };
|
|
1043
|
+
|
|
1044
|
+
const result = await db.transaction(async (tx) => {
|
|
1045
|
+
let prevDeviceStatus = 'unknown';
|
|
1046
|
+
const isWaitingForParts = validated.data.deviceStatusCode === 'waiting_for_parts';
|
|
1047
|
+
const reasonText = validated.data.partName
|
|
1048
|
+
? `Waiting for spare parts: ${validated.data.partName}`
|
|
1049
|
+
: validated.data.notes;
|
|
1050
|
+
|
|
1051
|
+
// Update linked device if present
|
|
1052
|
+
if (ticket.deviceId) {
|
|
1053
|
+
const dev = await tx.query.devices.findFirst({ where: eq(devices.id, ticket.deviceId) });
|
|
1054
|
+
prevDeviceStatus = dev?.currentStatusCode || 'unknown';
|
|
1055
|
+
|
|
1056
|
+
const limitationsNote = validated.data.deviceStatusCode === 'operational_with_limitations'
|
|
1057
|
+
? (validated.data.limitationsNote || validated.data.notes)
|
|
1058
|
+
: null;
|
|
1059
|
+
|
|
1060
|
+
await tx.update(devices)
|
|
1061
|
+
.set({
|
|
1062
|
+
currentStatusCode: validated.data.deviceStatusCode as any,
|
|
1063
|
+
statusLimitationsNote: limitationsNote,
|
|
1064
|
+
statusChangedAt: new Date(),
|
|
1065
|
+
statusChangedByUserId: session.id,
|
|
1066
|
+
updatedAt: new Date(),
|
|
1067
|
+
})
|
|
1068
|
+
.where(eq(devices.id, ticket.deviceId));
|
|
1069
|
+
|
|
1070
|
+
await tx.insert(deviceStatusHistory).values({
|
|
1071
|
+
organizationId: ticket.organizationId,
|
|
1072
|
+
deviceId: ticket.deviceId,
|
|
1073
|
+
previousStatusCode: prevDeviceStatus,
|
|
1074
|
+
newStatusCode: validated.data.deviceStatusCode,
|
|
1075
|
+
reason: `Ticket ${ticket.ticketNumber} status update: ${reasonText.substring(0, 100)}`,
|
|
1076
|
+
notes: limitationsNote
|
|
1077
|
+
? `Limitations: ${limitationsNote} | Note: ${validated.data.notes}`
|
|
1078
|
+
: (validated.data.supplierDetails
|
|
1079
|
+
? `${validated.data.notes} | Supplier/PO: ${validated.data.supplierDetails}`
|
|
1080
|
+
: validated.data.notes),
|
|
1081
|
+
effectiveAt: new Date(),
|
|
1082
|
+
changedByUserId: session.id,
|
|
1083
|
+
relatedTicketId: ticketId,
|
|
1084
|
+
});
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
// If waiting_for_parts, update ticket statusCode to waiting_parts_vendor and save waiting details
|
|
1088
|
+
// If moving out of waiting_parts_vendor, restore ticket statusCode to in_progress
|
|
1089
|
+
const ticketUpdateData: any = { updatedAt: new Date() };
|
|
1090
|
+
let newTicketStatus = ticket.statusCode;
|
|
1091
|
+
|
|
1092
|
+
if (isWaitingForParts) {
|
|
1093
|
+
newTicketStatus = 'waiting_parts_vendor';
|
|
1094
|
+
ticketUpdateData.statusCode = 'waiting_parts_vendor';
|
|
1095
|
+
ticketUpdateData.waitingReason = validated.data.partName || validated.data.notes;
|
|
1096
|
+
ticketUpdateData.waitingDependencyType = validated.data.supplierDetails || null;
|
|
1097
|
+
} else if (ticket.statusCode === 'waiting_parts_vendor') {
|
|
1098
|
+
newTicketStatus = 'in_progress';
|
|
1099
|
+
ticketUpdateData.statusCode = 'in_progress';
|
|
1100
|
+
ticketUpdateData.waitingReason = null;
|
|
1101
|
+
ticketUpdateData.waitingDependencyType = null;
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
// Add timeline event to ticket
|
|
1105
|
+
await tx.insert(serviceTicketEvents).values({
|
|
1106
|
+
serviceTicketId: ticketId,
|
|
1107
|
+
eventType: 'status_changed',
|
|
1108
|
+
previousValueJsonb: {
|
|
1109
|
+
deviceStatusCode: prevDeviceStatus,
|
|
1110
|
+
ticketStatusCode: ticket.statusCode,
|
|
1111
|
+
},
|
|
1112
|
+
newValueJsonb: {
|
|
1113
|
+
deviceStatusCode: validated.data.deviceStatusCode,
|
|
1114
|
+
ticketStatusCode: newTicketStatus,
|
|
1115
|
+
notes: validated.data.notes,
|
|
1116
|
+
partName: validated.data.partName || null,
|
|
1117
|
+
supplierDetails: validated.data.supplierDetails || null,
|
|
1118
|
+
},
|
|
1119
|
+
reason: reasonText,
|
|
1120
|
+
visibility: 'public',
|
|
1121
|
+
actorUserId: session.id,
|
|
1122
|
+
});
|
|
1123
|
+
|
|
1124
|
+
const [updated] = await tx.update(serviceTickets)
|
|
1125
|
+
.set(ticketUpdateData)
|
|
1126
|
+
.where(eq(serviceTickets.id, ticketId))
|
|
1127
|
+
.returning();
|
|
1128
|
+
|
|
1129
|
+
await createAuditLog(tx, {
|
|
1130
|
+
action: 'UPDATE', entityType: 'service_ticket', entityId: ticketId,
|
|
1131
|
+
actorId: session.id,
|
|
1132
|
+
details: { action: 'update_device_status', ...validated.data },
|
|
1133
|
+
});
|
|
1134
|
+
|
|
1135
|
+
return { success: true, data: updated };
|
|
1136
|
+
});
|
|
1137
|
+
|
|
1138
|
+
// Notify reporter about device status update (fire-and-forget)
|
|
1139
|
+
if (result.success && ticket.reportedByUserId) {
|
|
1140
|
+
const newStatus = validated.data.deviceStatusCode === 'waiting_for_parts'
|
|
1141
|
+
? 'waiting_parts_vendor'
|
|
1142
|
+
: (ticket.statusCode === 'waiting_parts_vendor' ? 'in_progress' : ticket.statusCode);
|
|
1143
|
+
notifyReporterOnStatusChange({
|
|
1144
|
+
ticketId,
|
|
1145
|
+
ticketNumber: ticket.ticketNumber,
|
|
1146
|
+
title: ticket.title,
|
|
1147
|
+
organizationId: ticket.organizationId,
|
|
1148
|
+
reporterUserId: ticket.reportedByUserId,
|
|
1149
|
+
newStatus,
|
|
1150
|
+
actorName: session.fullName || session.email || 'Biomedical Engineer',
|
|
1151
|
+
}).catch((err) => console.error('[Ticket] Reporter notification failed:', err));
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
return result;
|
|
1155
|
+
} catch (error: any) {
|
|
1156
|
+
return { success: false, error: error.message };
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
// ── Add Comment ─────────────────────────────────────────────────────────────────
|
|
1161
|
+
|
|
1162
|
+
export async function addTicketComment(ticketId: string, input: unknown) {
|
|
1163
|
+
const session = await requireAuth();
|
|
1164
|
+
const validated = createTicketCommentSchema.safeParse(input);
|
|
1165
|
+
if (!validated.success) return { success: false, error: validated.error.message };
|
|
1166
|
+
|
|
1167
|
+
try {
|
|
1168
|
+
const [comment] = await db.insert(serviceTicketComments).values({
|
|
1169
|
+
serviceTicketId: ticketId,
|
|
1170
|
+
authorUserId: session.id,
|
|
1171
|
+
visibility: validated.data.visibility,
|
|
1172
|
+
body: validated.data.body,
|
|
1173
|
+
}).returning();
|
|
1174
|
+
|
|
1175
|
+
// Notify the other party about new comment (fire-and-forget)
|
|
1176
|
+
if (validated.data.visibility === 'public') {
|
|
1177
|
+
const ticket = await db.query.serviceTickets.findFirst({ where: eq(serviceTickets.id, ticketId) });
|
|
1178
|
+
if (ticket) {
|
|
1179
|
+
notifyOnNewComment({
|
|
1180
|
+
ticketId,
|
|
1181
|
+
ticketNumber: ticket.ticketNumber,
|
|
1182
|
+
title: ticket.title,
|
|
1183
|
+
organizationId: ticket.organizationId,
|
|
1184
|
+
commentAuthorUserId: session.id,
|
|
1185
|
+
commentAuthorName: session.fullName || session.email || 'User',
|
|
1186
|
+
reporterUserId: ticket.reportedByUserId,
|
|
1187
|
+
assignedEngineerUserId: ticket.assignedEngineerUserId,
|
|
1188
|
+
commentPreview: validated.data.body,
|
|
1189
|
+
}).catch((err) => console.error('[Ticket] Comment notification failed:', err));
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
return { success: true, data: comment };
|
|
1194
|
+
} catch (error: any) {
|
|
1195
|
+
return { success: false, error: error.message };
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
// ── Get Ticket Comments ─────────────────────────────────────────────────────────
|
|
1200
|
+
|
|
1201
|
+
export async function getTicketComments(ticketId: string) {
|
|
1202
|
+
await requireAuth();
|
|
1203
|
+
try {
|
|
1204
|
+
const data = await db.query.serviceTicketComments.findMany({
|
|
1205
|
+
where: eq(serviceTicketComments.serviceTicketId, ticketId),
|
|
1206
|
+
with: { author: true },
|
|
1207
|
+
orderBy: [desc(serviceTicketComments.createdAt)],
|
|
1208
|
+
});
|
|
1209
|
+
return { success: true, data };
|
|
1210
|
+
} catch (error: any) {
|
|
1211
|
+
return { success: false, error: error.message };
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
// ── Get Ticket Timeline ─────────────────────────────────────────────────────────
|
|
1216
|
+
|
|
1217
|
+
export async function getTicketTimeline(ticketId: string) {
|
|
1218
|
+
await requireAuth();
|
|
1219
|
+
try {
|
|
1220
|
+
const data = await db.query.serviceTicketEvents.findMany({
|
|
1221
|
+
where: eq(serviceTicketEvents.serviceTicketId, ticketId),
|
|
1222
|
+
with: { actor: true },
|
|
1223
|
+
orderBy: [desc(serviceTicketEvents.createdAt)],
|
|
1224
|
+
});
|
|
1225
|
+
return { success: true, data };
|
|
1226
|
+
} catch (error: any) {
|
|
1227
|
+
return { success: false, error: error.message };
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
// ── Get Active Tickets for Device (duplicate check) ─────────────────────────────
|
|
1232
|
+
|
|
1233
|
+
export async function getDeviceActiveTickets(deviceId: string) {
|
|
1234
|
+
await requireAuth();
|
|
1235
|
+
try {
|
|
1236
|
+
const data = await db.query.serviceTickets.findMany({
|
|
1237
|
+
where: and(
|
|
1238
|
+
eq(serviceTickets.deviceId, deviceId),
|
|
1239
|
+
notInArray(serviceTickets.statusCode, ['closed', 'cancelled']),
|
|
1240
|
+
),
|
|
1241
|
+
with: { reportedByUser: true },
|
|
1242
|
+
orderBy: [desc(serviceTickets.createdAt)],
|
|
1243
|
+
});
|
|
1244
|
+
return { success: true, data };
|
|
1245
|
+
} catch (error: any) {
|
|
1246
|
+
return { success: false, error: error.message };
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
// ── Get Full Ticket History for Device ──────────────────────────────────────────
|
|
1251
|
+
|
|
1252
|
+
export async function getDeviceTicketHistory(deviceId: string) {
|
|
1253
|
+
await requireAuth();
|
|
1254
|
+
try {
|
|
1255
|
+
const data = await db.query.serviceTickets.findMany({
|
|
1256
|
+
where: eq(serviceTickets.deviceId, deviceId),
|
|
1257
|
+
with: {
|
|
1258
|
+
reportedByUser: true,
|
|
1259
|
+
assignedEngineer: true,
|
|
1260
|
+
},
|
|
1261
|
+
orderBy: [desc(serviceTickets.createdAt)],
|
|
1262
|
+
});
|
|
1263
|
+
return { success: true, data };
|
|
1264
|
+
} catch (error: any) {
|
|
1265
|
+
return { success: false, error: error.message };
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
// ── Get Electronic Signatures for Ticket ────────────────────────────────────────
|
|
1270
|
+
|
|
1271
|
+
export async function getTicketSignatures(ticketId: string) {
|
|
1272
|
+
await requireAuth();
|
|
1273
|
+
try {
|
|
1274
|
+
const signatures = await db.query.electronicSignatures.findMany({
|
|
1275
|
+
where: and(
|
|
1276
|
+
eq(electronicSignatures.entityType, 'ticket_resolution'),
|
|
1277
|
+
eq(electronicSignatures.entityId, ticketId),
|
|
1278
|
+
),
|
|
1279
|
+
with: {
|
|
1280
|
+
signerUser: true,
|
|
1281
|
+
},
|
|
1282
|
+
orderBy: [desc(electronicSignatures.signedAt)],
|
|
1283
|
+
});
|
|
1284
|
+
|
|
1285
|
+
const sigIds = signatures.map((s) => s.id);
|
|
1286
|
+
let events: any[] = [];
|
|
1287
|
+
if (sigIds.length > 0) {
|
|
1288
|
+
events = await db.query.signatureEvents.findMany({
|
|
1289
|
+
where: inArray(signatureEvents.signatureId, sigIds),
|
|
1290
|
+
orderBy: [desc(signatureEvents.timestamp)],
|
|
1291
|
+
});
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
return { success: true, data: { signatures, events } };
|
|
1295
|
+
} catch (error: any) {
|
|
1296
|
+
return { success: false, error: error.message };
|
|
1297
|
+
}
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
// ── Hold Ticket for Spare Parts ─────────────────────────────────────────────────
|
|
1301
|
+
|
|
1302
|
+
export async function holdTicketForParts(ticketId: string, input: { waitingReason: string; partDescription?: string }) {
|
|
1303
|
+
const session = await requireAuth();
|
|
1304
|
+
await requireRole('SYS_ADMIN', 'BIOMED_MGR', 'BIOMED_ENG', 'BIOMED_TECH');
|
|
1305
|
+
|
|
1306
|
+
if (!input.waitingReason?.trim()) {
|
|
1307
|
+
return { success: false, error: 'Reason for waiting on spare parts is required' };
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
try {
|
|
1311
|
+
const ticket = await db.query.serviceTickets.findFirst({ where: eq(serviceTickets.id, ticketId) });
|
|
1312
|
+
if (!ticket) return { success: false, error: 'Ticket not found' };
|
|
1313
|
+
if (ticket.statusCode !== 'in_progress') {
|
|
1314
|
+
return { success: false, error: 'Ticket must be in progress to put on hold for parts' };
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
return await db.transaction(async (tx) => {
|
|
1318
|
+
const [updated] = await tx.update(serviceTickets)
|
|
1319
|
+
.set({
|
|
1320
|
+
statusCode: 'waiting_parts_vendor',
|
|
1321
|
+
waitingReason: input.waitingReason.trim(),
|
|
1322
|
+
waitingDependencyType: 'parts_vendor',
|
|
1323
|
+
updatedAt: new Date(),
|
|
1324
|
+
})
|
|
1325
|
+
.where(eq(serviceTickets.id, ticketId))
|
|
1326
|
+
.returning();
|
|
1327
|
+
|
|
1328
|
+
// If device linked, update status to waiting_for_parts
|
|
1329
|
+
if (ticket.deviceId) {
|
|
1330
|
+
const currentDev = await tx.query.devices.findFirst({ where: eq(devices.id, ticket.deviceId) });
|
|
1331
|
+
await tx.update(devices)
|
|
1332
|
+
.set({
|
|
1333
|
+
currentStatusCode: 'waiting_for_parts',
|
|
1334
|
+
statusChangedAt: new Date(),
|
|
1335
|
+
statusChangedByUserId: session.id,
|
|
1336
|
+
updatedAt: new Date(),
|
|
1337
|
+
})
|
|
1338
|
+
.where(eq(devices.id, ticket.deviceId));
|
|
1339
|
+
|
|
1340
|
+
await tx.insert(deviceStatusHistory).values({
|
|
1341
|
+
organizationId: ticket.organizationId,
|
|
1342
|
+
deviceId: ticket.deviceId,
|
|
1343
|
+
previousStatusCode: currentDev?.currentStatusCode || 'under_repair',
|
|
1344
|
+
newStatusCode: 'waiting_for_parts',
|
|
1345
|
+
reason: `Ticket ${ticket.ticketNumber} on hold: waiting for spare parts (${input.waitingReason.trim()})`,
|
|
1346
|
+
notes: input.partDescription ? `Part details: ${input.partDescription}` : null,
|
|
1347
|
+
effectiveAt: new Date(),
|
|
1348
|
+
changedByUserId: session.id,
|
|
1349
|
+
relatedTicketId: ticketId,
|
|
1350
|
+
});
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
await tx.insert(serviceTicketEvents).values({
|
|
1354
|
+
serviceTicketId: ticketId,
|
|
1355
|
+
eventType: 'status_changed',
|
|
1356
|
+
previousValueJsonb: { statusCode: 'in_progress' },
|
|
1357
|
+
newValueJsonb: { statusCode: 'waiting_parts_vendor', waitingReason: input.waitingReason.trim() },
|
|
1358
|
+
reason: input.waitingReason.trim(),
|
|
1359
|
+
visibility: 'public',
|
|
1360
|
+
actorUserId: session.id,
|
|
1361
|
+
});
|
|
1362
|
+
|
|
1363
|
+
await createAuditLog(tx, {
|
|
1364
|
+
action: 'UPDATE', entityType: 'service_ticket', entityId: ticketId,
|
|
1365
|
+
actorId: session.id, details: { action: 'hold_for_parts', ...input },
|
|
1366
|
+
});
|
|
1367
|
+
|
|
1368
|
+
return { success: true, data: updated };
|
|
1369
|
+
});
|
|
1370
|
+
} catch (error: any) {
|
|
1371
|
+
return { success: false, error: error.message };
|
|
1372
|
+
}
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
// ── Resume Ticket Work (Parts Received) ─────────────────────────────────────────
|
|
1376
|
+
|
|
1377
|
+
export async function resumeTicketWork(ticketId: string) {
|
|
1378
|
+
const session = await requireAuth();
|
|
1379
|
+
await requireRole('SYS_ADMIN', 'BIOMED_MGR', 'BIOMED_ENG', 'BIOMED_TECH');
|
|
1380
|
+
|
|
1381
|
+
try {
|
|
1382
|
+
const ticket = await db.query.serviceTickets.findFirst({ where: eq(serviceTickets.id, ticketId) });
|
|
1383
|
+
if (!ticket) return { success: false, error: 'Ticket not found' };
|
|
1384
|
+
if (!['waiting_parts_vendor', 'waiting_requester'].includes(ticket.statusCode)) {
|
|
1385
|
+
return { success: false, error: `Cannot resume ticket from '${ticket.statusCode}' status` };
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
return await db.transaction(async (tx) => {
|
|
1389
|
+
const [updated] = await tx.update(serviceTickets)
|
|
1390
|
+
.set({
|
|
1391
|
+
statusCode: 'in_progress',
|
|
1392
|
+
waitingReason: null,
|
|
1393
|
+
waitingDependencyType: null,
|
|
1394
|
+
updatedAt: new Date(),
|
|
1395
|
+
})
|
|
1396
|
+
.where(eq(serviceTickets.id, ticketId))
|
|
1397
|
+
.returning();
|
|
1398
|
+
|
|
1399
|
+
// If device linked, update status back to under_repair
|
|
1400
|
+
if (ticket.deviceId) {
|
|
1401
|
+
await tx.update(devices)
|
|
1402
|
+
.set({
|
|
1403
|
+
currentStatusCode: 'under_repair',
|
|
1404
|
+
statusChangedAt: new Date(),
|
|
1405
|
+
statusChangedByUserId: session.id,
|
|
1406
|
+
updatedAt: new Date(),
|
|
1407
|
+
})
|
|
1408
|
+
.where(eq(devices.id, ticket.deviceId));
|
|
1409
|
+
|
|
1410
|
+
await tx.insert(deviceStatusHistory).values({
|
|
1411
|
+
organizationId: ticket.organizationId,
|
|
1412
|
+
deviceId: ticket.deviceId,
|
|
1413
|
+
previousStatusCode: 'waiting_for_parts',
|
|
1414
|
+
newStatusCode: 'under_repair',
|
|
1415
|
+
reason: `Ticket ${ticket.ticketNumber}: spare parts arrived, repair resumed`,
|
|
1416
|
+
effectiveAt: new Date(),
|
|
1417
|
+
changedByUserId: session.id,
|
|
1418
|
+
relatedTicketId: ticketId,
|
|
1419
|
+
});
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
await tx.insert(serviceTicketEvents).values({
|
|
1423
|
+
serviceTicketId: ticketId,
|
|
1424
|
+
eventType: 'status_changed',
|
|
1425
|
+
previousValueJsonb: { statusCode: ticket.statusCode },
|
|
1426
|
+
newValueJsonb: { statusCode: 'in_progress' },
|
|
1427
|
+
reason: 'Spare parts received / dependency cleared, repair work resumed',
|
|
1428
|
+
visibility: 'public',
|
|
1429
|
+
actorUserId: session.id,
|
|
1430
|
+
});
|
|
1431
|
+
|
|
1432
|
+
await createAuditLog(tx, {
|
|
1433
|
+
action: 'UPDATE', entityType: 'service_ticket', entityId: ticketId,
|
|
1434
|
+
actorId: session.id, details: { action: 'resume_work' },
|
|
1435
|
+
});
|
|
1436
|
+
|
|
1437
|
+
return { success: true, data: updated };
|
|
1438
|
+
});
|
|
1439
|
+
} catch (error: any) {
|
|
1440
|
+
return { success: false, error: error.message };
|
|
1441
|
+
}
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
|
|
1445
|
+
|