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);
|
||||
}
|
||||
Reference in New Issue
Block a user