feat(03-03): Ticket service, category service, API routes, and 28 integration tests
- ticket-category-service.ts: createCategory, updateCategory, listCategories - ticket-service.ts: createTicket (TKT-NNNN numbering), updateTicket, getTicket, listTickets, transitionTicketStatus (guard map), resolveTicket (idempotent) - 5 ticket API routes: GET/POST /api/tickets, GET/PUT /api/tickets/[id], POST /api/tickets/[id]/status - 2 category API routes: GET/POST /api/ticket-categories, PUT /api/ticket-categories/[id] - 28 integration tests: lifecycle, transitions, deactivated category rejection, idempotent resolve, cross-tenant isolation
This commit is contained in:
76
src/app/api/tickets/[id]/status/route.ts
Normal file
76
src/app/api/tickets/[id]/status/route.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { withPermission } from "@/lib/middleware/authorize";
|
||||
import { withTenantContext } from "@/lib/prisma-tenant";
|
||||
import { transitionTicketStatus } from "@/lib/services/ticket-service";
|
||||
import { TicketStatus } from "@prisma/client";
|
||||
|
||||
/**
|
||||
* POST /api/tickets/[id]/status
|
||||
*
|
||||
* Transition a ticket to a new status.
|
||||
* Enforces the guard map: OPEN->ASSIGNED|CLOSED, ASSIGNED->OPEN|RESOLVED,
|
||||
* RESOLVED->CLOSED|OPEN, CLOSED->(terminal).
|
||||
*
|
||||
* Accepts: { status: TicketStatus }
|
||||
*
|
||||
* Requires: update on Ticket subject.
|
||||
*
|
||||
* Response:
|
||||
* 200 OK — updated ticket with new status
|
||||
* 400 Bad Request — invalid or disallowed transition
|
||||
* 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("update", "Ticket")(
|
||||
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 } = body as Record<string, unknown>;
|
||||
|
||||
if (!status || typeof status !== "string") {
|
||||
return NextResponse.json({ error: "status is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const validStatuses = Object.values(TicketStatus) as string[];
|
||||
if (!validStatuses.includes(status)) {
|
||||
return NextResponse.json(
|
||||
{ error: `Invalid status. Must be one of: ${validStatuses.join(", ")}` },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const tenantPrisma = withTenantContext(user.tenantId);
|
||||
|
||||
try {
|
||||
const ticket = await transitionTicketStatus(
|
||||
tenantPrisma,
|
||||
id,
|
||||
status as TicketStatus
|
||||
);
|
||||
return NextResponse.json(ticket);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Failed to transition ticket status";
|
||||
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