feat(03-04): job order service, API routes, and 17 passing integration tests
- Added checkTicketAutoResolve/checkTicketRevertToOpen to ticket-service.ts - Created job-order-service.ts: createJobOrder, updateJobOrderStatus, updateJobOrder, getJobOrder, listJobOrders, getMyJobOrders with VALID_JO_TRANSITIONS guard map - PENDING->IN_PROGRESS->COMPLETED(outcomeNotes required)/CANCELLED lifecycle enforced - OPEN ticket auto-transitions to ASSIGNED on first job order creation - Auto-resolves ticket when all non-cancelled jobs COMPLETED - Reverts ticket to OPEN when all jobs CANCELLED - Created POST /api/tickets/[id]/job-orders, GET/PUT /api/job-orders/[id], POST /api/job-orders/[id]/status, GET /api/job-orders (with TECHNICIAN filter) - 17 integration tests: lifecycle, auto-resolve, revert-to-open, partial completion, technician self-service, cross-tenant isolation — all green
This commit is contained in:
101
src/app/api/job-orders/[id]/route.ts
Normal file
101
src/app/api/job-orders/[id]/route.ts
Normal file
@@ -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<string, unknown>;
|
||||
|
||||
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);
|
||||
}
|
||||
86
src/app/api/job-orders/[id]/status/route.ts
Normal file
86
src/app/api/job-orders/[id]/status/route.ts
Normal file
@@ -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<string, unknown>;
|
||||
|
||||
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);
|
||||
}
|
||||
69
src/app/api/job-orders/route.ts
Normal file
69
src/app/api/job-orders/route.ts
Normal file
@@ -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);
|
||||
}
|
||||
75
src/app/api/tickets/[id]/job-orders/route.ts
Normal file
75
src/app/api/tickets/[id]/job-orders/route.ts
Normal file
@@ -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<string, unknown>;
|
||||
|
||||
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);
|
||||
}
|
||||
619
src/lib/__tests__/job-order-service.test.ts
Normal file
619
src/lib/__tests__/job-order-service.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
468
src/lib/services/job-order-service.ts
Normal file
468
src/lib/services/job-order-service.ts
Normal file
@@ -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, JobOrderStatus[]> = {
|
||||
[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<string> {
|
||||
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<string, unknown> = { 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<string, unknown> = {};
|
||||
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<string, unknown> = {};
|
||||
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,
|
||||
});
|
||||
}
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user