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