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:
60
src/app/api/ticket-categories/[id]/route.ts
Normal file
60
src/app/api/ticket-categories/[id]/route.ts
Normal 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);
|
||||
}
|
||||
86
src/app/api/ticket-categories/route.ts
Normal file
86
src/app/api/ticket-categories/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 { 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 });
|
||||
}
|
||||
}
|
||||
);
|
||||
102
src/app/api/tickets/[id]/route.ts
Normal file
102
src/app/api/tickets/[id]/route.ts
Normal 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);
|
||||
}
|
||||
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);
|
||||
}
|
||||
120
src/app/api/tickets/route.ts
Normal file
120
src/app/api/tickets/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
);
|
||||
Reference in New Issue
Block a user