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:
kevin-asprec
2026-03-05 07:40:10 +08:00
parent b0562a0a12
commit 74d26d92f0
8 changed files with 1522 additions and 0 deletions

View File

@@ -0,0 +1,60 @@
import { NextRequest, NextResponse } from "next/server";
import { withPermission } from "@/lib/middleware/authorize";
import { withTenantContext } from "@/lib/prisma-tenant";
import { updateCategory } from "@/lib/services/ticket-category-service";
/**
* PUT /api/ticket-categories/[id]
*
* Update a ticket category (rename, change description, or deactivate).
* Accepts: { name?, description?, isActive? }
*
* Requires: manage on Ticket subject (admin only).
*
* Response:
* 200 OK — updated category
* 400 Bad Request — validation error
* 404 Not Found — category not found
* 401 Unauthorized — no session
* 403 Forbidden — insufficient role (non-admin)
*/
export function PUT(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
return withPermission("manage", "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 { name, description, isActive } = body as Record<string, unknown>;
const tenantPrisma = withTenantContext(user.tenantId);
try {
const category = await updateCategory(tenantPrisma, id, {
name: name as string | undefined,
description: description as string | undefined,
isActive: isActive as boolean | undefined,
});
return NextResponse.json(category);
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to update category";
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 { createCategory, listCategories } from "@/lib/services/ticket-category-service";
/**
* GET /api/ticket-categories
*
* List all ticket categories for the authenticated tenant.
* Supports optional query param: activeOnly=true
*
* Requires: read on Ticket subject.
*
* Response:
* 200 OK — array of categories
* 401 Unauthorized — no session
* 403 Forbidden — insufficient role
*/
export const GET = withPermission("read", "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 { searchParams } = new URL(req.url);
const activeOnly = searchParams.get("activeOnly") === "true";
const tenantPrisma = withTenantContext(user.tenantId);
const categories = await listCategories(tenantPrisma, { activeOnly });
return NextResponse.json(categories);
}
);
/**
* POST /api/ticket-categories
*
* Create a new ticket category (admin/staff only via manage permission).
* Accepts: { name, description? }
*
* Requires: manage on Ticket subject.
*
* Response:
* 201 Created — created category
* 400 Bad Request — validation error or duplicate name
* 401 Unauthorized — no session
* 403 Forbidden — insufficient role (non-admin)
*/
export const POST = withPermission("manage", "Ticket")(
async (req: NextRequest, { user }) => {
if (!user.tenantId) {
return NextResponse.json(
{ error: "No tenant context — super-admins must use the admin API" },
{ status: 400 }
);
}
let body: unknown;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
}
const { name, description } = body as Record<string, unknown>;
if (!name || typeof name !== "string") {
return NextResponse.json({ error: "name is required" }, { status: 400 });
}
const tenantPrisma = withTenantContext(user.tenantId);
try {
const category = await createCategory(tenantPrisma, user.tenantId, {
name,
description: description as string | undefined,
});
return NextResponse.json(category, { status: 201 });
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to create category";
return NextResponse.json({ error: message }, { status: 400 });
}
}
);

View File

@@ -0,0 +1,102 @@
import { NextRequest, NextResponse } from "next/server";
import { withPermission } from "@/lib/middleware/authorize";
import { withTenantContext } from "@/lib/prisma-tenant";
import { getTicket, updateTicket } from "@/lib/services/ticket-service";
import { TicketPriority } from "@prisma/client";
/**
* GET /api/tickets/[id]
*
* Get a single ticket with its related data.
*
* Requires: read on Ticket subject.
*
* Response:
* 200 OK — ticket with category, subscriber, createdBy
* 404 Not Found — ticket 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", "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;
const tenantPrisma = withTenantContext(user.tenantId);
const ticket = await getTicket(tenantPrisma, id);
if (!ticket) {
return NextResponse.json({ error: "Ticket not found" }, { status: 404 });
}
return NextResponse.json(ticket);
}
)(req);
}
/**
* PUT /api/tickets/[id]
*
* Update ticket metadata (subject, description, category, priority, notes).
* Does NOT change ticket status — use POST /api/tickets/[id]/status for that.
*
* Accepts: { subject?, description?, categoryId?, priority?, notes? }
*
* Requires: update on Ticket subject.
*
* Response:
* 200 OK — updated ticket
* 400 Bad Request — validation error
* 404 Not Found — ticket not found
* 401 Unauthorized — no session
* 403 Forbidden — insufficient role
*/
export function PUT(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 { subject, description, categoryId, priority, notes } = body as Record<string, unknown>;
const tenantPrisma = withTenantContext(user.tenantId);
try {
const ticket = await updateTicket(tenantPrisma, id, {
subject: subject as string | undefined,
description: description as string | undefined,
categoryId: categoryId as string | undefined,
priority: priority as TicketPriority | undefined,
notes: notes as string | undefined,
});
return NextResponse.json(ticket);
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to update ticket";
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,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);
}

View File

@@ -0,0 +1,120 @@
import { NextRequest, NextResponse } from "next/server";
import { withPermission } from "@/lib/middleware/authorize";
import { withTenantContext } from "@/lib/prisma-tenant";
import { createTicket, listTickets } from "@/lib/services/ticket-service";
import { TicketPriority, TicketSource, TicketStatus } from "@prisma/client";
/**
* GET /api/tickets
*
* List tickets for the authenticated tenant.
* Supports optional query filters: status, categoryId, priority, subscriberId, page, limit.
*
* Requires: read on Ticket subject.
*
* Response:
* 200 OK — paginated ticket list
* 401 Unauthorized — no session
* 403 Forbidden — insufficient role
*/
export const GET = withPermission("read", "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 { searchParams } = new URL(req.url);
const status = searchParams.get("status") as TicketStatus | null;
const categoryId = searchParams.get("categoryId") ?? undefined;
const priority = searchParams.get("priority") as TicketPriority | null;
const subscriberId = searchParams.get("subscriberId") ?? undefined;
const page = parseInt(searchParams.get("page") ?? "1", 10);
const limit = parseInt(searchParams.get("limit") ?? "20", 10);
const tenantPrisma = withTenantContext(user.tenantId);
const result = await listTickets(tenantPrisma, {
status: status ?? undefined,
categoryId,
priority: priority ?? undefined,
subscriberId,
page,
limit,
});
return NextResponse.json(result);
}
);
/**
* POST /api/tickets
*
* Create a new support ticket.
* Accepts: { subject, description, categoryId, priority?, subscriberId? }
*
* Requires: create on Ticket subject.
*
* Response:
* 201 Created — created ticket
* 400 Bad Request — validation error or deactivated category
* 401 Unauthorized — no session
* 403 Forbidden — insufficient role
*/
export const POST = withPermission("create", "Ticket")(
async (req: NextRequest, { user }) => {
if (!user.tenantId) {
return NextResponse.json(
{ error: "No tenant context — super-admins must use the admin API" },
{ status: 400 }
);
}
let body: unknown;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
}
const {
subject,
description,
categoryId,
priority,
subscriberId,
source,
} = body as Record<string, unknown>;
if (!subject || typeof subject !== "string") {
return NextResponse.json({ error: "subject is required" }, { status: 400 });
}
if (!description || typeof description !== "string") {
return NextResponse.json({ error: "description is required" }, { status: 400 });
}
if (!categoryId || typeof categoryId !== "string") {
return NextResponse.json({ error: "categoryId is required" }, { status: 400 });
}
const tenantPrisma = withTenantContext(user.tenantId);
try {
const ticket = await createTicket(tenantPrisma, user.tenantId, {
subject,
description,
categoryId,
priority: priority as TicketPriority | undefined,
subscriberId: subscriberId as string | undefined,
createdById: user.id,
source: source as TicketSource | undefined,
});
return NextResponse.json(ticket, { status: 201 });
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to create ticket";
return NextResponse.json({ error: message }, { status: 400 });
}
}
);