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:
kevin-asprec
2026-03-05 08:04:21 +08:00
parent d59f1d55ea
commit 86284f1f5b
7 changed files with 1503 additions and 0 deletions

View 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);
}

View 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);
}

View 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);
}

View 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);
}

View 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);
});
});

View 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,
});
}

View File

@@ -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)
// ---------------------------------------------------------------------------