diff --git a/src/app/api/job-orders/[id]/route.ts b/src/app/api/job-orders/[id]/route.ts new file mode 100644 index 0000000..c38fc6b --- /dev/null +++ b/src/app/api/job-orders/[id]/route.ts @@ -0,0 +1,101 @@ +import { NextRequest, NextResponse } from "next/server"; +import { withPermission } from "@/lib/middleware/authorize"; +import { withTenantContext } from "@/lib/prisma-tenant"; +import { getJobOrder, updateJobOrder } from "@/lib/services/job-order-service"; + +/** + * GET /api/job-orders/[id] + * + * Get a single job order with ticket, assignedTo, and createdBy. + * + * Requires: read on JobOrder subject. + * + * Response: + * 200 OK — job order with relations + * 404 Not Found — job order not found in tenant scope + * 401 Unauthorized — no session + * 403 Forbidden — insufficient role + */ +export function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + return withPermission("read", "JobOrder")( + async (_req: NextRequest, { user }) => { + if (!user.tenantId) { + return NextResponse.json( + { error: "No tenant context — super-admins must use the admin API" }, + { status: 400 } + ); + } + + const { id } = await params; + const tenantPrisma = withTenantContext(user.tenantId); + const jobOrder = await getJobOrder(tenantPrisma, id); + + if (!jobOrder) { + return NextResponse.json({ error: "Job order not found" }, { status: 404 }); + } + + return NextResponse.json(jobOrder); + } + )(req); +} + +/** + * PUT /api/job-orders/[id] + * + * Update job order metadata (jobType, description, assignedToId, scheduledDate). + * Does NOT change status — use POST /api/job-orders/[id]/status for that. + * + * Accepts: { jobType?, description?, assignedToId?, scheduledDate? } + * + * Requires: update on JobOrder subject. + * + * Response: + * 200 OK — updated job order + * 400 Bad Request — validation error + * 404 Not Found — job order not found + * 401 Unauthorized — no session + * 403 Forbidden — insufficient role + */ +export function PUT(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + return withPermission("update", "JobOrder")( + async (req: NextRequest, { user }) => { + if (!user.tenantId) { + return NextResponse.json( + { error: "No tenant context — super-admins must use the admin API" }, + { status: 400 } + ); + } + + const { id } = await params; + + let body: unknown; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + const { jobType, description, assignedToId, scheduledDate } = body as Record; + + const tenantPrisma = withTenantContext(user.tenantId); + + try { + const jobOrder = await updateJobOrder(tenantPrisma, id, { + jobType: jobType as string | undefined, + description: description as string | undefined, + assignedToId: assignedToId as string | undefined, + scheduledDate: scheduledDate !== undefined + ? (scheduledDate === null ? null : new Date(scheduledDate as string)) + : undefined, + }); + return NextResponse.json(jobOrder); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to update job order"; + if (message.includes("not found")) { + return NextResponse.json({ error: message }, { status: 404 }); + } + return NextResponse.json({ error: message }, { status: 400 }); + } + } + )(req); +} diff --git a/src/app/api/job-orders/[id]/status/route.ts b/src/app/api/job-orders/[id]/status/route.ts new file mode 100644 index 0000000..dbe5441 --- /dev/null +++ b/src/app/api/job-orders/[id]/status/route.ts @@ -0,0 +1,86 @@ +import { NextRequest, NextResponse } from "next/server"; +import { withPermission } from "@/lib/middleware/authorize"; +import { withTenantContext } from "@/lib/prisma-tenant"; +import { updateJobOrderStatus } from "@/lib/services/job-order-service"; +import { JobOrderStatus } from "@prisma/client"; + +/** + * POST /api/job-orders/[id]/status + * + * Update a job order's status. Enforces transition guard map. + * + * Accepts: { status, outcomeNotes?, cancelReason? } + * + * Valid transitions: + * PENDING -> IN_PROGRESS | CANCELLED + * IN_PROGRESS -> COMPLETED | CANCELLED + * COMPLETED -> (terminal) + * CANCELLED -> (terminal) + * + * COMPLETED requires outcomeNotes. + * + * Side effects: + * - After COMPLETED: triggers ticket auto-resolve if all non-cancelled jobs done + * - After CANCELLED: triggers ticket revert-to-open if all jobs cancelled + * + * Technician self-service: TECHNICIAN role can update status of their own assigned orders. + * Staff (ADMIN, OFFICE_STAFF) can update any job order status. + * + * Requires: update on JobOrder subject. + * + * Response: + * 200 OK — updated job order with synced ticket status + * 400 Bad Request — invalid transition or missing outcomeNotes + * 404 Not Found — job order not found + * 401 Unauthorized — no session + * 403 Forbidden — insufficient role (TECHNICIAN trying to update others' job orders) + */ +export function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + return withPermission("update", "JobOrder")( + async (req: NextRequest, { user }) => { + if (!user.tenantId) { + return NextResponse.json( + { error: "No tenant context — super-admins must use the admin API" }, + { status: 400 } + ); + } + + const { id } = await params; + + let body: unknown; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + const { status, outcomeNotes, cancelReason } = body as Record; + + if (!status) { + return NextResponse.json({ error: "status is required" }, { status: 400 }); + } + + const tenantPrisma = withTenantContext(user.tenantId); + + try { + const jobOrder = await updateJobOrderStatus( + tenantPrisma, + user.tenantId, + id, + { + status: status as JobOrderStatus, + outcomeNotes: outcomeNotes as string | undefined, + cancelReason: cancelReason as string | undefined, + } + ); + return NextResponse.json(jobOrder); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to update job order status"; + if (message.includes("not found")) { + return NextResponse.json({ error: message }, { status: 404 }); + } + return NextResponse.json({ error: message }, { status: 400 }); + } + } + )(req); +} diff --git a/src/app/api/job-orders/route.ts b/src/app/api/job-orders/route.ts new file mode 100644 index 0000000..9e8edc3 --- /dev/null +++ b/src/app/api/job-orders/route.ts @@ -0,0 +1,69 @@ +import { NextRequest, NextResponse } from "next/server"; +import { withPermission } from "@/lib/middleware/authorize"; +import { withTenantContext } from "@/lib/prisma-tenant"; +import { listJobOrders, getMyJobOrders } from "@/lib/services/job-order-service"; +import { JobOrderStatus, Role } from "@prisma/client"; + +/** + * GET /api/job-orders + * + * List job orders. TECHNICIAN users automatically see only their assigned orders. + * ADMIN and OFFICE_STAFF see all job orders (with optional filters). + * + * Query params: ticketId?, assignedToId?, status?, page?, limit? + * + * Requires: read on JobOrder subject. + * + * Response: + * 200 OK — { jobOrders, total, page, limit } + * 401 Unauthorized — no session + * 403 Forbidden — insufficient role + */ +export function GET(req: NextRequest) { + return withPermission("read", "JobOrder")( + async (req: NextRequest, { user }) => { + if (!user.tenantId) { + return NextResponse.json( + { error: "No tenant context — super-admins must use the admin API" }, + { status: 400 } + ); + } + + const url = new URL(req.url); + const ticketId = url.searchParams.get("ticketId") ?? undefined; + const assignedToId = url.searchParams.get("assignedToId") ?? undefined; + const statusParam = url.searchParams.get("status") ?? undefined; + const page = parseInt(url.searchParams.get("page") ?? "1", 10); + const limit = parseInt(url.searchParams.get("limit") ?? "20", 10); + + const status = statusParam as JobOrderStatus | undefined; + + const tenantPrisma = withTenantContext(user.tenantId); + + // TECHNICIAN role: auto-filter to own assigned orders + const isTechnicianOnly = + user.roles.includes(Role.TECHNICIAN) && + !user.roles.includes(Role.ADMIN) && + !user.roles.includes(Role.OFFICE_STAFF); + + if (isTechnicianOnly) { + const result = await getMyJobOrders(tenantPrisma, user.id, { + status, + page, + limit, + }); + return NextResponse.json(result); + } + + const result = await listJobOrders(tenantPrisma, { + ticketId, + assignedToId, + status, + page, + limit, + }); + + return NextResponse.json(result); + } + )(req); +} diff --git a/src/app/api/tickets/[id]/job-orders/route.ts b/src/app/api/tickets/[id]/job-orders/route.ts new file mode 100644 index 0000000..72355f6 --- /dev/null +++ b/src/app/api/tickets/[id]/job-orders/route.ts @@ -0,0 +1,75 @@ +import { NextRequest, NextResponse } from "next/server"; +import { withPermission } from "@/lib/middleware/authorize"; +import { withTenantContext } from "@/lib/prisma-tenant"; +import { createJobOrder } from "@/lib/services/job-order-service"; + +/** + * POST /api/tickets/[id]/job-orders + * + * Create a new job order for a ticket. Assigns a technician. + * + * Accepts: { jobType, description?, assignedToId, scheduledDate? } + * + * Side effects: + * - Auto-transitions OPEN ticket to ASSIGNED on first job order + * + * Requires: create on JobOrder subject. + * + * Response: + * 201 Created — job order with ticket, assignedTo, createdBy + * 400 Bad Request — validation error (closed ticket, non-technician assignee) + * 404 Not Found — ticket not found + * 401 Unauthorized — no session + * 403 Forbidden — insufficient role + */ +export function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + return withPermission("create", "JobOrder")( + async (req: NextRequest, { user }) => { + if (!user.tenantId) { + return NextResponse.json( + { error: "No tenant context — super-admins must use the admin API" }, + { status: 400 } + ); + } + + const { id: ticketId } = await params; + + let body: unknown; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + const { jobType, description, assignedToId, scheduledDate } = body as Record; + + if (!jobType) { + return NextResponse.json({ error: "jobType is required" }, { status: 400 }); + } + + if (!assignedToId) { + return NextResponse.json({ error: "assignedToId is required" }, { status: 400 }); + } + + const tenantPrisma = withTenantContext(user.tenantId); + + try { + const jobOrder = await createJobOrder(tenantPrisma, user.tenantId, { + ticketId, + jobType: jobType as string, + description: description as string | undefined, + assignedToId: assignedToId as string, + scheduledDate: scheduledDate ? new Date(scheduledDate as string) : undefined, + createdById: user.id, + }); + return NextResponse.json(jobOrder, { status: 201 }); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to create job order"; + if (message.includes("not found")) { + return NextResponse.json({ error: message }, { status: 404 }); + } + return NextResponse.json({ error: message }, { status: 400 }); + } + } + )(req); +} diff --git a/src/lib/__tests__/job-order-service.test.ts b/src/lib/__tests__/job-order-service.test.ts new file mode 100644 index 0000000..9aaa8f9 --- /dev/null +++ b/src/lib/__tests__/job-order-service.test.ts @@ -0,0 +1,619 @@ +/** + * Job Order System Integration Tests + * + * Tests the full job order lifecycle: + * - createJobOrder succeeds, returns JO-0001 + * - createJobOrder auto-transitions OPEN ticket to ASSIGNED + * - Second job order gets JO-0002, ticket stays ASSIGNED + * - createJobOrder rejects non-TECHNICIAN assignee + * - createJobOrder rejects CLOSED ticket + * - updateJobOrderStatus PENDING -> IN_PROGRESS (sets startedAt) + * - updateJobOrderStatus IN_PROGRESS -> COMPLETED (sets completedAt, requires outcomeNotes) + * - COMPLETED without outcomeNotes throws + * - Invalid transition (COMPLETED -> IN_PROGRESS) throws + * - Auto-resolve: 2 job orders, complete both -> ticket RESOLVED + * - Revert-to-open: 1 job order, cancel it -> ticket ASSIGNED -> OPEN + * - Partial completion: 2 job orders, complete 1, cancel 1 -> ticket auto-resolves + * - All cancelled with none completed -> ticket reverts to OPEN + * - getMyJobOrders returns only technician's assigned orders + * - Cross-tenant isolation + * + * These tests require a live PostgreSQL database connection. + * + * CLEANUP ORDER: + * jobOrders -> tickets -> ticketCategories -> subscribers -> servicePlans -> + * tenantSettings -> accountingPeriods -> accounts -> users -> tenant + */ + +import { prisma } from "@/lib/prisma"; +import { withTenantContext } from "@/lib/prisma-tenant"; +import { createTenant } from "@/lib/tenant"; +import { + createJobOrder, + updateJobOrderStatus, + getJobOrder, + listJobOrders, + getMyJobOrders, +} from "@/lib/services/job-order-service"; +import { getTicket } from "@/lib/services/ticket-service"; +import { listCategories } from "@/lib/services/ticket-category-service"; +import { createTicket } from "@/lib/services/ticket-service"; +import { JobOrderStatus, Role, TicketStatus } from "@prisma/client"; + +// --------------------------------------------------------------------------- +// Shared test state +// --------------------------------------------------------------------------- + +const TS = Date.now(); + +let tenantAId: string; +let tenantBId: string; +let adminUserAId: string; +let adminUserBId: string; +let technicianAId: string; +let technicianBId: string; +let nonTechnicianId: string; // OFFICE_STAFF only + +let activeCategoryId: string; + +// --------------------------------------------------------------------------- +// Setup helpers +// --------------------------------------------------------------------------- + +function tA() { + return withTenantContext(tenantAId); +} + +function tB() { + return withTenantContext(tenantBId); +} + +async function makeTicket(subject = "Test Ticket") { + return createTicket(tA(), tenantAId, { + subject, + description: "Test description", + categoryId: activeCategoryId, + createdById: adminUserAId, + }); +} + +// --------------------------------------------------------------------------- +// Before all: provision tenants and users +// --------------------------------------------------------------------------- + +beforeAll(async () => { + // Tenant A + const resultA = await createTenant({ + businessName: `JO ISP A ${TS}`, + ownerFirstName: "Alice", + ownerLastName: "Admin", + ownerEmail: `jo-admin-a-${TS}@test.com`, + password: "password123", + }); + tenantAId = resultA.tenant.id; + adminUserAId = resultA.user.id; + + // Tenant B + const resultB = await createTenant({ + businessName: `JO ISP B ${TS}`, + ownerFirstName: "Bob", + ownerLastName: "Admin", + ownerEmail: `jo-admin-b-${TS}@test.com`, + password: "password123", + }); + tenantBId = resultB.tenant.id; + adminUserBId = resultB.user.id; + + // Create a TECHNICIAN user for Tenant A + technicianAId = (await prisma.user.create({ + data: { + tenantId: tenantAId, + email: `tech-a-${TS}@test.com`, + passwordHash: "hashed", + firstName: "Tech", + lastName: "A", + roles: [Role.TECHNICIAN], + }, + })).id; + + // Create a TECHNICIAN user for Tenant B + technicianBId = (await prisma.user.create({ + data: { + tenantId: tenantBId, + email: `tech-b-${TS}@test.com`, + passwordHash: "hashed", + firstName: "Tech", + lastName: "B", + roles: [Role.TECHNICIAN], + }, + })).id; + + // Create a non-TECHNICIAN user (OFFICE_STAFF only) for Tenant A + nonTechnicianId = (await prisma.user.create({ + data: { + tenantId: tenantAId, + email: `staff-a-${TS}@test.com`, + passwordHash: "hashed", + firstName: "Staff", + lastName: "A", + roles: [Role.OFFICE_STAFF], + }, + })).id; + + // Get the active category for ticket creation + const cats = await listCategories(tA(), { activeOnly: true }); + activeCategoryId = cats[0]?.id; +}); + +// --------------------------------------------------------------------------- +// After all: cleanup in dependency order +// --------------------------------------------------------------------------- + +afterAll(async () => { + // Job orders first + await prisma.jobOrder.deleteMany({ where: { tenantId: { in: [tenantAId, tenantBId] } } }); + // Tickets + await prisma.ticket.deleteMany({ where: { tenantId: { in: [tenantAId, tenantBId] } } }); + // Ticket categories (seeded + any created in tests) + await prisma.ticketCategory.deleteMany({ where: { tenantId: { in: [tenantAId, tenantBId] } } }); + // Subscribers (none in this suite, safe to call) + await prisma.subscriber.deleteMany({ where: { tenantId: { in: [tenantAId, tenantBId] } } }); + // Service plans + await prisma.servicePlan.deleteMany({ where: { tenantId: { in: [tenantAId, tenantBId] } } }); + // Tenant settings + await prisma.tenantSettings.deleteMany({ where: { tenantId: { in: [tenantAId, tenantBId] } } }); + // Accounting periods + await prisma.accountingPeriod.deleteMany({ where: { tenantId: { in: [tenantAId, tenantBId] } } }); + // Journal entries + await prisma.journalEntryLine.deleteMany({ where: { tenantId: { in: [tenantAId, tenantBId] } } }); + await prisma.journalEntry.updateMany({ + where: { tenantId: { in: [tenantAId, tenantBId] } }, + data: { reversesEntryId: null }, + }); + await prisma.journalEntry.deleteMany({ where: { tenantId: { in: [tenantAId, tenantBId] } } }); + // Chart of accounts + await prisma.account.deleteMany({ where: { tenantId: { in: [tenantAId, tenantBId] } } }); + // Users then tenants + await prisma.user.deleteMany({ where: { tenantId: { in: [tenantAId, tenantBId] } } }); + await prisma.tenant.deleteMany({ where: { id: { in: [tenantAId, tenantBId] } } }); +}); + +// --------------------------------------------------------------------------- +// createJobOrder tests +// --------------------------------------------------------------------------- + +describe("createJobOrder", () => { + it("creates a job order with JO-0001 number", async () => { + const ticket = await makeTicket("First JO ticket"); + + const jo = await createJobOrder(tA(), tenantAId, { + ticketId: ticket.id, + jobType: "Installation", + description: "Install fiber cable", + assignedToId: technicianAId, + createdById: adminUserAId, + }); + + expect(jo.orderNumber).toBe("JO-0001"); + expect(jo.jobType).toBe("Installation"); + expect(jo.description).toBe("Install fiber cable"); + expect(jo.status).toBe(JobOrderStatus.PENDING); + expect(jo.assignedTo.id).toBe(technicianAId); + expect(jo.tenantId).toBe(tenantAId); + }); + + it("auto-transitions OPEN ticket to ASSIGNED when first job order created", async () => { + // Get the ticket from the previous test (should now be ASSIGNED) + const allJOs = await listJobOrders(tA(), { assignedToId: technicianAId }); + const firstJO = allJOs.jobOrders.find( + (jo: { orderNumber: string }) => jo.orderNumber === "JO-0001" + ); + expect(firstJO).toBeTruthy(); + + // Verify the ticket transitioned to ASSIGNED + const ticket = await getTicket(tA(), firstJO!.ticket.id); + expect(ticket!.status).toBe(TicketStatus.ASSIGNED); + }); + + it("creates second job order with JO-0002, ticket stays ASSIGNED", async () => { + // Create a new ticket + const ticket = await makeTicket("Second JO ticket"); + + // First job order auto-assigns ticket + await createJobOrder(tA(), tenantAId, { + ticketId: ticket.id, + jobType: "Repair", + assignedToId: technicianAId, + createdById: adminUserAId, + }); + + // Second job order + const jo2 = await createJobOrder(tA(), tenantAId, { + ticketId: ticket.id, + jobType: "Maintenance", + assignedToId: technicianAId, + createdById: adminUserAId, + }); + + expect(jo2.orderNumber).toBe("JO-0003"); // JO-0001 was first test, JO-0002 above, this is JO-0003 + // Ticket should still be ASSIGNED + const updatedTicket = await getTicket(tA(), ticket.id); + expect(updatedTicket!.status).toBe(TicketStatus.ASSIGNED); + }); + + it("rejects non-TECHNICIAN assignee", async () => { + const ticket = await makeTicket("Non-tech assignee ticket"); + + await expect( + createJobOrder(tA(), tenantAId, { + ticketId: ticket.id, + jobType: "Repair", + assignedToId: nonTechnicianId, + createdById: adminUserAId, + }) + ).rejects.toThrow(/TECHNICIAN role/); + }); + + it("rejects creation for CLOSED ticket", async () => { + const ticket = await makeTicket("Closed ticket JO test"); + + // Manually close the ticket via raw prisma (bypasses service guard) + await prisma.ticket.update({ + where: { id: ticket.id }, + data: { status: TicketStatus.CLOSED, closedAt: new Date() }, + }); + + await expect( + createJobOrder(tA(), tenantAId, { + ticketId: ticket.id, + jobType: "Repair", + assignedToId: technicianAId, + createdById: adminUserAId, + }) + ).rejects.toThrow(/CLOSED ticket/); + }); +}); + +// --------------------------------------------------------------------------- +// updateJobOrderStatus tests +// --------------------------------------------------------------------------- + +describe("updateJobOrderStatus", () => { + let ticketId: string; + let joId: string; + + beforeAll(async () => { + const ticket = await makeTicket("Status lifecycle test ticket"); + ticketId = ticket.id; + + const jo = await createJobOrder(tA(), tenantAId, { + ticketId, + jobType: "Repair", + assignedToId: technicianAId, + createdById: adminUserAId, + }); + joId = jo.id; + }); + + it("PENDING -> IN_PROGRESS sets startedAt", async () => { + const updated = await updateJobOrderStatus(tA(), tenantAId, joId, { + status: JobOrderStatus.IN_PROGRESS, + }); + + expect(updated.status).toBe(JobOrderStatus.IN_PROGRESS); + expect(updated.startedAt).toBeTruthy(); + }); + + it("IN_PROGRESS -> COMPLETED sets completedAt and records outcomeNotes", async () => { + const updated = await updateJobOrderStatus(tA(), tenantAId, joId, { + status: JobOrderStatus.COMPLETED, + outcomeNotes: "Replaced the fiber cable, connection restored.", + }); + + expect(updated.status).toBe(JobOrderStatus.COMPLETED); + expect(updated.completedAt).toBeTruthy(); + expect(updated.outcomeNotes).toBe("Replaced the fiber cable, connection restored."); + }); + + it("COMPLETED without outcomeNotes throws", async () => { + const ticket = await makeTicket("No outcome notes ticket"); + const jo = await createJobOrder(tA(), tenantAId, { + ticketId: ticket.id, + jobType: "Repair", + assignedToId: technicianAId, + createdById: adminUserAId, + }); + + // Advance to IN_PROGRESS + await updateJobOrderStatus(tA(), tenantAId, jo.id, { + status: JobOrderStatus.IN_PROGRESS, + }); + + await expect( + updateJobOrderStatus(tA(), tenantAId, jo.id, { + status: JobOrderStatus.COMPLETED, + // No outcomeNotes + }) + ).rejects.toThrow(/outcomeNotes.*required|outcome notes.*required/i); + }); + + it("invalid transition COMPLETED -> IN_PROGRESS throws", async () => { + // joId is now COMPLETED from the first test + await expect( + updateJobOrderStatus(tA(), tenantAId, joId, { + status: JobOrderStatus.IN_PROGRESS, + }) + ).rejects.toThrow(/Invalid status transition/); + }); +}); + +// --------------------------------------------------------------------------- +// Ticket auto-resolve tests +// --------------------------------------------------------------------------- + +describe("Ticket auto-resolve: all non-cancelled jobs COMPLETED", () => { + it("auto-resolves ticket when 2 job orders are both COMPLETED", async () => { + const ticket = await makeTicket("Auto-resolve test ticket"); + + const jo1 = await createJobOrder(tA(), tenantAId, { + ticketId: ticket.id, + jobType: "Repair", + assignedToId: technicianAId, + createdById: adminUserAId, + }); + + const jo2 = await createJobOrder(tA(), tenantAId, { + ticketId: ticket.id, + jobType: "Maintenance", + assignedToId: technicianAId, + createdById: adminUserAId, + }); + + // Complete first job order — ticket should still be ASSIGNED + await updateJobOrderStatus(tA(), tenantAId, jo1.id, { + status: JobOrderStatus.IN_PROGRESS, + }); + await updateJobOrderStatus(tA(), tenantAId, jo1.id, { + status: JobOrderStatus.COMPLETED, + outcomeNotes: "First job done", + }); + + let ticketState = await getTicket(tA(), ticket.id); + expect(ticketState!.status).toBe(TicketStatus.ASSIGNED); // Not yet resolved + + // Complete second job order — ticket should auto-resolve + await updateJobOrderStatus(tA(), tenantAId, jo2.id, { + status: JobOrderStatus.IN_PROGRESS, + }); + await updateJobOrderStatus(tA(), tenantAId, jo2.id, { + status: JobOrderStatus.COMPLETED, + outcomeNotes: "Second job done", + }); + + ticketState = await getTicket(tA(), ticket.id); + expect(ticketState!.status).toBe(TicketStatus.RESOLVED); + }); +}); + +// --------------------------------------------------------------------------- +// Ticket revert-to-open tests +// --------------------------------------------------------------------------- + +describe("Ticket revert-to-open: all jobs CANCELLED", () => { + it("reverts ticket to OPEN when single job order is cancelled", async () => { + const ticket = await makeTicket("Revert-to-open test ticket"); + + const jo = await createJobOrder(tA(), tenantAId, { + ticketId: ticket.id, + jobType: "Installation", + assignedToId: technicianAId, + createdById: adminUserAId, + }); + + // Ticket should be ASSIGNED after job order creation + let ticketState = await getTicket(tA(), ticket.id); + expect(ticketState!.status).toBe(TicketStatus.ASSIGNED); + + // Cancel the job order + await updateJobOrderStatus(tA(), tenantAId, jo.id, { + status: JobOrderStatus.CANCELLED, + cancelReason: "Subscriber no longer needs this", + }); + + // Ticket should revert to OPEN + ticketState = await getTicket(tA(), ticket.id); + expect(ticketState!.status).toBe(TicketStatus.OPEN); + }); + + it("all cancelled with none completed -> ticket reverts to OPEN", async () => { + const ticket = await makeTicket("All cancelled revert test"); + + const jo1 = await createJobOrder(tA(), tenantAId, { + ticketId: ticket.id, + jobType: "Repair", + assignedToId: technicianAId, + createdById: adminUserAId, + }); + + const jo2 = await createJobOrder(tA(), tenantAId, { + ticketId: ticket.id, + jobType: "Maintenance", + assignedToId: technicianAId, + createdById: adminUserAId, + }); + + // Cancel both — ticket should revert to OPEN + await updateJobOrderStatus(tA(), tenantAId, jo1.id, { + status: JobOrderStatus.CANCELLED, + }); + + // After first cancel, ticket is still ASSIGNED (jo2 is still PENDING) + let ticketState = await getTicket(tA(), ticket.id); + expect(ticketState!.status).toBe(TicketStatus.ASSIGNED); + + // Cancel second job order + await updateJobOrderStatus(tA(), tenantAId, jo2.id, { + status: JobOrderStatus.CANCELLED, + }); + + // Now all are cancelled — ticket should be OPEN + ticketState = await getTicket(tA(), ticket.id); + expect(ticketState!.status).toBe(TicketStatus.OPEN); + }); +}); + +// --------------------------------------------------------------------------- +// Partial completion test +// --------------------------------------------------------------------------- + +describe("Partial completion: complete 1, cancel 1 -> ticket auto-resolves", () => { + it("auto-resolves ticket when 1 completed and 1 cancelled (non-cancelled all done)", async () => { + const ticket = await makeTicket("Partial completion test ticket"); + + const jo1 = await createJobOrder(tA(), tenantAId, { + ticketId: ticket.id, + jobType: "Repair", + assignedToId: technicianAId, + createdById: adminUserAId, + }); + + const jo2 = await createJobOrder(tA(), tenantAId, { + ticketId: ticket.id, + jobType: "Maintenance", + assignedToId: technicianAId, + createdById: adminUserAId, + }); + + // Cancel jo2 + await updateJobOrderStatus(tA(), tenantAId, jo2.id, { + status: JobOrderStatus.CANCELLED, + cancelReason: "Not needed", + }); + + // Ticket should still be ASSIGNED (jo1 is still PENDING) + let ticketState = await getTicket(tA(), ticket.id); + expect(ticketState!.status).toBe(TicketStatus.ASSIGNED); + + // Complete jo1 — only non-cancelled job is now completed -> auto-resolve + await updateJobOrderStatus(tA(), tenantAId, jo1.id, { + status: JobOrderStatus.IN_PROGRESS, + }); + await updateJobOrderStatus(tA(), tenantAId, jo1.id, { + status: JobOrderStatus.COMPLETED, + outcomeNotes: "Fixed the issue", + }); + + // Ticket should now be RESOLVED + ticketState = await getTicket(tA(), ticket.id); + expect(ticketState!.status).toBe(TicketStatus.RESOLVED); + }); +}); + +// --------------------------------------------------------------------------- +// getMyJobOrders — technician self-service +// --------------------------------------------------------------------------- + +describe("getMyJobOrders", () => { + it("returns only job orders assigned to the technician", async () => { + // Create a second technician for this tenant + const tech2Id = (await prisma.user.create({ + data: { + tenantId: tenantAId, + email: `tech2-a-${TS}@test.com`, + passwordHash: "hashed", + firstName: "Tech2", + lastName: "A", + roles: [Role.TECHNICIAN], + }, + })).id; + + const ticket = await makeTicket("getMyJobOrders test ticket"); + + // Create job order for technicianAId + await createJobOrder(tA(), tenantAId, { + ticketId: ticket.id, + jobType: "Repair", + assignedToId: technicianAId, + createdById: adminUserAId, + }); + + // Create job order for tech2Id + await createJobOrder(tA(), tenantAId, { + ticketId: ticket.id, + jobType: "Maintenance", + assignedToId: tech2Id, + createdById: adminUserAId, + }); + + // getMyJobOrders should only return jobs assigned to technicianAId + const result = await getMyJobOrders(tA(), technicianAId); + const myJobIds = result.jobOrders.map((jo: { assignedTo: { id: string } }) => jo.assignedTo.id); + const allMine = myJobIds.every((id: string) => id === technicianAId); + expect(allMine).toBe(true); + + // tech2 should not see technicianA's jobs + const tech2Result = await getMyJobOrders(tA(), tech2Id); + const tech2JobIds = tech2Result.jobOrders.map( + (jo: { assignedTo: { id: string } }) => jo.assignedTo.id + ); + const noneFromTech1 = tech2JobIds.every((id: string) => id === tech2Id); + expect(noneFromTech1).toBe(true); + + // Cleanup: delete tech2's job orders before deleting the user (FK constraint) + await prisma.jobOrder.deleteMany({ where: { assignedToId: tech2Id } }); + await prisma.user.delete({ where: { id: tech2Id } }); + }); +}); + +// --------------------------------------------------------------------------- +// getJobOrder tests +// --------------------------------------------------------------------------- + +describe("getJobOrder", () => { + it("returns job order with related data", async () => { + const ticket = await makeTicket("getJobOrder test ticket"); + const jo = await createJobOrder(tA(), tenantAId, { + ticketId: ticket.id, + jobType: "Installation", + assignedToId: technicianAId, + createdById: adminUserAId, + }); + + const fetched = await getJobOrder(tA(), jo.id); + expect(fetched).not.toBeNull(); + expect(fetched!.id).toBe(jo.id); + expect(fetched!.ticket).toBeTruthy(); + expect(fetched!.assignedTo.id).toBe(technicianAId); + expect(fetched!.createdBy.id).toBe(adminUserAId); + }); + + it("returns null for non-existent job order", async () => { + const result = await getJobOrder(tA(), "non-existent-id"); + expect(result).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// Cross-tenant isolation test +// --------------------------------------------------------------------------- + +describe("Cross-tenant isolation", () => { + it("Tenant B cannot see Tenant A job orders", async () => { + const ticket = await makeTicket("Isolation test ticket"); + const jo = await createJobOrder(tA(), tenantAId, { + ticketId: ticket.id, + jobType: "Repair", + assignedToId: technicianAId, + createdById: adminUserAId, + }); + + // Tenant B tries to get the job order + const fromB = await getJobOrder(tB(), jo.id); + expect(fromB).toBeNull(); + + // Tenant B list should not include Tenant A's job orders + const listB = await listJobOrders(tB()); + const bIds = listB.jobOrders.map((j: { id: string }) => j.id); + expect(bIds).not.toContain(jo.id); + }); +}); diff --git a/src/lib/services/job-order-service.ts b/src/lib/services/job-order-service.ts new file mode 100644 index 0000000..c2a71d8 --- /dev/null +++ b/src/lib/services/job-order-service.ts @@ -0,0 +1,468 @@ +/** + * JobOrderService — Job order CRUD, status lifecycle, and ticket synchronization. + * + * ARCHITECTURE: + * Job orders are execution units assigned to technicians for resolving support tickets. + * One ticket can have multiple job orders (1:many). + * + * STATUS LIFECYCLE: + * PENDING -> IN_PROGRESS (technician starts work) + * PENDING -> CANCELLED (staff cancels before start) + * IN_PROGRESS -> COMPLETED (work done, requires outcomeNotes) + * IN_PROGRESS -> CANCELLED (work cancelled mid-progress) + * COMPLETED -> (terminal — no further transitions) + * CANCELLED -> (terminal — no further transitions) + * + * TICKET SYNCHRONIZATION: + * - Creating first job order on OPEN ticket: ticket -> ASSIGNED + * - Job order COMPLETED: checkTicketAutoResolve (all non-cancelled done -> ticket RESOLVED) + * - Job order CANCELLED: checkTicketRevertToOpen (all cancelled -> ticket OPEN) + * + * SEQUENTIAL NUMBERING: + * Job order numbers are auto-generated as JO-NNNN (e.g., JO-0001, JO-0042). + * Same pattern as TKT-NNNN in ticket service. + */ + +import { JobOrderStatus, TicketStatus } from "@prisma/client"; +import { transitionTicketStatus, checkTicketAutoResolve, checkTicketRevertToOpen } from "./ticket-service"; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type TenantPrismaClient = any; + +// --------------------------------------------------------------------------- +// Status transition guard map +// --------------------------------------------------------------------------- + +/** + * Valid transitions for each job order status. + * COMPLETED and CANCELLED are terminal — no further transitions. + */ +export const VALID_JO_TRANSITIONS: Record = { + [JobOrderStatus.PENDING]: [JobOrderStatus.IN_PROGRESS, JobOrderStatus.CANCELLED], + [JobOrderStatus.IN_PROGRESS]: [JobOrderStatus.COMPLETED, JobOrderStatus.CANCELLED], + [JobOrderStatus.COMPLETED]: [], + [JobOrderStatus.CANCELLED]: [], +}; + +// --------------------------------------------------------------------------- +// Input/output types +// --------------------------------------------------------------------------- + +export interface CreateJobOrderInput { + ticketId: string; + jobType: string; + description?: string; + assignedToId: string; + scheduledDate?: Date; + createdById: string; +} + +export interface UpdateJobOrderInput { + jobType?: string; + description?: string; + assignedToId?: string; + scheduledDate?: Date | null; +} + +export interface UpdateJobOrderStatusInput { + status: JobOrderStatus; + outcomeNotes?: string; + cancelReason?: string; +} + +export interface ListJobOrdersOptions { + ticketId?: string; + assignedToId?: string; + status?: JobOrderStatus; + page?: number; + limit?: number; +} + +// --------------------------------------------------------------------------- +// generateOrderNumber +// --------------------------------------------------------------------------- + +/** + * Generate the next sequential job order number for this tenant. + * Format: JO-NNNN (e.g., JO-0001, JO-0042) + * + * Finds the most recently created job order with a JO- prefix, + * parses the last 4 digits, increments, and pads to 4 characters. + * Starts at JO-0001 if no job orders exist. + */ +async function generateOrderNumber(tenantPrisma: TenantPrismaClient): Promise { + const lastOrder = await tenantPrisma.jobOrder.findFirst({ + where: { orderNumber: { startsWith: "JO-" } }, + orderBy: { orderNumber: "desc" }, + select: { orderNumber: true }, + }); + + if (!lastOrder) { + return "JO-0001"; + } + + const lastNum = parseInt(lastOrder.orderNumber.replace("JO-", ""), 10); + const nextNum = isNaN(lastNum) ? 1 : lastNum + 1; + return `JO-${String(nextNum).padStart(4, "0")}`; +} + +// --------------------------------------------------------------------------- +// createJobOrder +// --------------------------------------------------------------------------- + +/** + * Create a new job order for a ticket. + * + * Validates: + * - Ticket exists and is not CLOSED + * - assignedToId user has TECHNICIAN role + * + * Side effects: + * - Generates sequential order number (JO-NNNN) + * - If ticket status is OPEN, auto-transitions to ASSIGNED + * + * @throws Error if ticket not found, ticket is CLOSED, or assignee is not a TECHNICIAN + */ +export async function createJobOrder( + tenantPrisma: TenantPrismaClient, + tenantId: string, + input: CreateJobOrderInput +) { + const { + ticketId, + jobType, + description, + assignedToId, + scheduledDate, + createdById, + } = input; + + if (!jobType || !jobType.trim()) { + throw new Error("Job type is required"); + } + + // Validate ticket exists and is not CLOSED + const ticket = await tenantPrisma.ticket.findFirst({ + where: { id: ticketId }, + select: { id: true, status: true, ticketNumber: true }, + }); + + if (!ticket) { + throw new Error(`Ticket not found: ${ticketId}`); + } + + if (ticket.status === TicketStatus.CLOSED) { + throw new Error( + `Cannot create job order for CLOSED ticket ${ticket.ticketNumber}` + ); + } + + // Validate assignee has TECHNICIAN role + const assignee = await tenantPrisma.user.findFirst({ + where: { id: assignedToId }, + select: { id: true, roles: true, firstName: true, lastName: true }, + }); + + if (!assignee) { + throw new Error(`Assignee user not found: ${assignedToId}`); + } + + if (!assignee.roles.includes("TECHNICIAN")) { + throw new Error( + `User ${assignee.firstName} ${assignee.lastName} does not have TECHNICIAN role` + ); + } + + const orderNumber = await generateOrderNumber(tenantPrisma); + + const jobOrder = await tenantPrisma.jobOrder.create({ + data: { + tenantId, + orderNumber, + ticketId, + jobType: jobType.trim(), + description: description?.trim() ?? null, + assignedToId, + status: JobOrderStatus.PENDING, + scheduledDate: scheduledDate ?? null, + createdById, + }, + include: { + ticket: { + select: { id: true, ticketNumber: true, status: true, subject: true }, + }, + assignedTo: { + select: { id: true, firstName: true, lastName: true, email: true }, + }, + createdBy: { + select: { id: true, firstName: true, lastName: true, email: true }, + }, + }, + }); + + // Auto-transition OPEN ticket to ASSIGNED when first job order is created + if (ticket.status === TicketStatus.OPEN) { + await transitionTicketStatus(tenantPrisma, ticketId, TicketStatus.ASSIGNED); + } + + return jobOrder; +} + +// --------------------------------------------------------------------------- +// updateJobOrderStatus +// --------------------------------------------------------------------------- + +/** + * Update a job order's status, enforcing the transition guard map. + * + * Valid transitions: + * PENDING -> IN_PROGRESS | CANCELLED + * IN_PROGRESS -> COMPLETED | CANCELLED + * COMPLETED -> (none — terminal) + * CANCELLED -> (none — terminal) + * + * Side effects: + * - Sets startedAt on transition to IN_PROGRESS + * - Sets completedAt on transition to COMPLETED + * - Sets cancelledAt on transition to CANCELLED + * - COMPLETED requires outcomeNotes + * - After COMPLETED: triggers checkTicketAutoResolve + * - After CANCELLED: triggers checkTicketRevertToOpen + * + * @throws Error if job order not found, transition invalid, or outcomeNotes missing on COMPLETED + */ +export async function updateJobOrderStatus( + tenantPrisma: TenantPrismaClient, + _tenantId: string, + jobOrderId: string, + input: UpdateJobOrderStatusInput +) { + const { status: newStatus, outcomeNotes, cancelReason } = input; + + const jobOrder = await tenantPrisma.jobOrder.findFirst({ + where: { id: jobOrderId }, + select: { id: true, status: true, ticketId: true, orderNumber: true }, + }); + + if (!jobOrder) { + throw new Error(`Job order not found: ${jobOrderId}`); + } + + const currentStatus = jobOrder.status as JobOrderStatus; + const allowedTransitions = VALID_JO_TRANSITIONS[currentStatus]; + + if (!allowedTransitions.includes(newStatus)) { + throw new Error( + `Invalid status transition: ${currentStatus} -> ${newStatus}. ` + + `Allowed: ${allowedTransitions.length > 0 ? allowedTransitions.join(", ") : "none (terminal state)"}` + ); + } + + // COMPLETED requires outcome notes + if (newStatus === JobOrderStatus.COMPLETED && !outcomeNotes?.trim()) { + throw new Error("Outcome notes are required when completing a job order"); + } + + const data: Record = { status: newStatus }; + + if (newStatus === JobOrderStatus.IN_PROGRESS) { + data.startedAt = new Date(); + } + if (newStatus === JobOrderStatus.COMPLETED) { + data.completedAt = new Date(); + data.outcomeNotes = outcomeNotes!.trim(); + } + if (newStatus === JobOrderStatus.CANCELLED) { + data.cancelledAt = new Date(); + if (cancelReason?.trim()) { + data.cancelReason = cancelReason.trim(); + } + } + + const updated = await tenantPrisma.jobOrder.update({ + where: { id: jobOrderId }, + data, + include: { + ticket: { + select: { id: true, ticketNumber: true, status: true, subject: true }, + }, + assignedTo: { + select: { id: true, firstName: true, lastName: true, email: true }, + }, + createdBy: { + select: { id: true, firstName: true, lastName: true, email: true }, + }, + }, + }); + + // Trigger ticket synchronization after status change + if (newStatus === JobOrderStatus.COMPLETED) { + await checkTicketAutoResolve(tenantPrisma, jobOrder.ticketId); + } + if (newStatus === JobOrderStatus.CANCELLED) { + await checkTicketRevertToOpen(tenantPrisma, jobOrder.ticketId); + } + + return updated; +} + +// --------------------------------------------------------------------------- +// updateJobOrder (metadata update) +// --------------------------------------------------------------------------- + +/** + * Update job order metadata (jobType, description, assignedToId, scheduledDate). + * Does NOT change status — use updateJobOrderStatus for that. + * + * @throws Error if job order not found, or new assignee is not a TECHNICIAN + */ +export async function updateJobOrder( + tenantPrisma: TenantPrismaClient, + jobOrderId: string, + input: UpdateJobOrderInput +) { + const { jobType, description, assignedToId, scheduledDate } = input; + + const data: Record = {}; + if (jobType !== undefined) data.jobType = jobType.trim(); + if (description !== undefined) data.description = description?.trim() ?? null; + if (scheduledDate !== undefined) data.scheduledDate = scheduledDate; + + if (assignedToId !== undefined) { + const assignee = await tenantPrisma.user.findFirst({ + where: { id: assignedToId }, + select: { id: true, roles: true, firstName: true, lastName: true }, + }); + + if (!assignee) { + throw new Error(`Assignee user not found: ${assignedToId}`); + } + + if (!assignee.roles.includes("TECHNICIAN")) { + throw new Error( + `User ${assignee.firstName} ${assignee.lastName} does not have TECHNICIAN role` + ); + } + + data.assignedToId = assignedToId; + } + + try { + return await tenantPrisma.jobOrder.update({ + where: { id: jobOrderId }, + data, + include: { + ticket: { + select: { id: true, ticketNumber: true, status: true, subject: true }, + }, + assignedTo: { + select: { id: true, firstName: true, lastName: true, email: true }, + }, + createdBy: { + select: { id: true, firstName: true, lastName: true, email: true }, + }, + }, + }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + if (message.includes("Record to update not found") || message.includes("P2025")) { + throw new Error(`Job order not found: ${jobOrderId}`); + } + throw err; + } +} + +// --------------------------------------------------------------------------- +// getJobOrder +// --------------------------------------------------------------------------- + +/** + * Get a single job order by ID, including ticket, assignedTo, and createdBy. + * + * Returns null if not found in tenant scope. + */ +export async function getJobOrder( + tenantPrisma: TenantPrismaClient, + jobOrderId: string +) { + return tenantPrisma.jobOrder.findFirst({ + where: { id: jobOrderId }, + include: { + ticket: { + select: { id: true, ticketNumber: true, status: true, subject: true }, + }, + assignedTo: { + select: { id: true, firstName: true, lastName: true, email: true }, + }, + createdBy: { + select: { id: true, firstName: true, lastName: true, email: true }, + }, + }, + }); +} + +// --------------------------------------------------------------------------- +// listJobOrders +// --------------------------------------------------------------------------- + +/** + * List job orders with optional filters and pagination. + * + * Ordered by createdAt descending (newest first). + */ +export async function listJobOrders( + tenantPrisma: TenantPrismaClient, + options: ListJobOrdersOptions = {} +) { + const { ticketId, assignedToId, status, page = 1, limit = 20 } = options; + const skip = (page - 1) * limit; + + const where: Record = {}; + if (ticketId !== undefined) where.ticketId = ticketId; + if (assignedToId !== undefined) where.assignedToId = assignedToId; + if (status !== undefined) where.status = status; + + const [jobOrders, total] = await Promise.all([ + tenantPrisma.jobOrder.findMany({ + where, + include: { + ticket: { + select: { id: true, ticketNumber: true, status: true, subject: true }, + }, + assignedTo: { + select: { id: true, firstName: true, lastName: true, email: true }, + }, + createdBy: { + select: { id: true, firstName: true, lastName: true, email: true }, + }, + }, + orderBy: { createdAt: "desc" }, + skip, + take: limit, + }), + tenantPrisma.jobOrder.count({ where }), + ]); + + return { jobOrders, total, page, limit }; +} + +// --------------------------------------------------------------------------- +// getMyJobOrders (technician self-service) +// --------------------------------------------------------------------------- + +/** + * Get job orders assigned to a specific technician. + * Used for technician self-service — only returns their own assigned orders. + * + * Ordered by createdAt descending. + */ +export async function getMyJobOrders( + tenantPrisma: TenantPrismaClient, + technicianUserId: string, + options: { status?: JobOrderStatus; page?: number; limit?: number } = {} +) { + return listJobOrders(tenantPrisma, { + assignedToId: technicianUserId, + ...options, + }); +} diff --git a/src/lib/services/ticket-service.ts b/src/lib/services/ticket-service.ts index 397666b..96dd874 100644 --- a/src/lib/services/ticket-service.ts +++ b/src/lib/services/ticket-service.ts @@ -371,6 +371,91 @@ export async function transitionTicketStatus( }); } +// --------------------------------------------------------------------------- +// checkTicketAutoResolve (called by job-order-service after COMPLETED) +// --------------------------------------------------------------------------- + +/** + * Check if all non-cancelled job orders on this ticket are COMPLETED. + * If so, auto-resolve the ticket. Called after a job order is marked COMPLETED. + * + * Logic: + * - Count non-cancelled job orders for the ticket + * - If count > 0 AND all have status COMPLETED, resolve the ticket + * - If count == 0 (all cancelled), do NOT auto-resolve here (checkTicketRevertToOpen handles that) + */ +export async function checkTicketAutoResolve( + tenantPrisma: TenantPrismaClient, + ticketId: string +): Promise { + const nonCancelledOrders = await tenantPrisma.jobOrder.findMany({ + where: { ticketId, status: { not: "CANCELLED" } }, + select: { id: true, status: true }, + }); + + if (nonCancelledOrders.length === 0) { + // All are cancelled — let checkTicketRevertToOpen handle revert + return; + } + + const allCompleted = nonCancelledOrders.every( + (jo: { status: string }) => jo.status === "COMPLETED" + ); + + if (allCompleted) { + await resolveTicket(tenantPrisma, ticketId); + } +} + +// --------------------------------------------------------------------------- +// checkTicketRevertToOpen (called by job-order-service after CANCELLED) +// --------------------------------------------------------------------------- + +/** + * Check if all job orders on this ticket are CANCELLED. + * If so, revert the ticket from ASSIGNED back to OPEN. + * Called after a job order is marked CANCELLED. + * + * Logic: + * - Count total job orders for the ticket + * - If ALL are CANCELLED and ticket status is ASSIGNED, transition to OPEN + */ +export async function checkTicketRevertToOpen( + tenantPrisma: TenantPrismaClient, + ticketId: string +): Promise { + const allOrders = await tenantPrisma.jobOrder.findMany({ + where: { ticketId }, + select: { id: true, status: true }, + }); + + if (allOrders.length === 0) { + return; + } + + const allCancelled = allOrders.every( + (jo: { status: string }) => jo.status === "CANCELLED" + ); + + if (!allCancelled) { + return; + } + + // All job orders are cancelled — revert ticket to OPEN if currently ASSIGNED + const ticket = await tenantPrisma.ticket.findFirst({ + where: { id: ticketId }, + select: { id: true, status: true }, + }); + + if (!ticket) { + return; + } + + if (ticket.status === TicketStatus.ASSIGNED) { + await transitionTicketStatus(tenantPrisma, ticketId, TicketStatus.OPEN); + } +} + // --------------------------------------------------------------------------- // resolveTicket (idempotent) // ---------------------------------------------------------------------------