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:
121
src/lib/services/ticket-category-service.ts
Normal file
121
src/lib/services/ticket-category-service.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* TicketCategoryService — Admin-configurable ticket category CRUD.
|
||||
*
|
||||
* ARCHITECTURE:
|
||||
* - Categories are tenant-scoped and admin-configurable
|
||||
* - Default ISP categories are seeded at tenant creation (see tenant.ts)
|
||||
* - Deactivated categories (isActive=false) cannot be used for new tickets
|
||||
* - Soft-deactivation preserves category history on existing tickets
|
||||
*/
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type TenantPrismaClient = any;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Input/output types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface CreateCategoryInput {
|
||||
name: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface UpdateCategoryInput {
|
||||
name?: string;
|
||||
description?: string;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// createCategory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Create a new ticket category for this tenant.
|
||||
*
|
||||
* @throws Error if name is blank or already exists in tenant
|
||||
*/
|
||||
export async function createCategory(
|
||||
tenantPrisma: TenantPrismaClient,
|
||||
tenantId: string,
|
||||
input: CreateCategoryInput
|
||||
) {
|
||||
const { name, description } = input;
|
||||
|
||||
if (!name || !name.trim()) {
|
||||
throw new Error("Category name is required");
|
||||
}
|
||||
|
||||
try {
|
||||
return await tenantPrisma.ticketCategory.create({
|
||||
data: {
|
||||
tenantId,
|
||||
name: name.trim(),
|
||||
description: description?.trim() ?? null,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
if (message.includes("Unique constraint") || message.includes("P2002")) {
|
||||
throw new Error(`Category name "${name.trim()}" already exists`);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// updateCategory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Update a category's name, description, or isActive flag.
|
||||
* Used by admins to rename or deactivate categories.
|
||||
*
|
||||
* @throws Error if category not found
|
||||
*/
|
||||
export async function updateCategory(
|
||||
tenantPrisma: TenantPrismaClient,
|
||||
categoryId: string,
|
||||
input: UpdateCategoryInput
|
||||
) {
|
||||
const { name, description, isActive } = input;
|
||||
|
||||
const data: Record<string, unknown> = {};
|
||||
if (name !== undefined) data.name = name.trim();
|
||||
if (description !== undefined) data.description = description.trim() || null;
|
||||
if (isActive !== undefined) data.isActive = isActive;
|
||||
|
||||
try {
|
||||
return await tenantPrisma.ticketCategory.update({
|
||||
where: { id: categoryId },
|
||||
data,
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
if (message.includes("Record to update not found") || message.includes("P2025")) {
|
||||
throw new Error(`Category not found: ${categoryId}`);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// listCategories
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* List all categories for this tenant.
|
||||
*
|
||||
* @param options.activeOnly - If true, only return isActive=true categories (default: false)
|
||||
*/
|
||||
export async function listCategories(
|
||||
tenantPrisma: TenantPrismaClient,
|
||||
options: { activeOnly?: boolean } = {}
|
||||
) {
|
||||
const { activeOnly = false } = options;
|
||||
|
||||
return tenantPrisma.ticketCategory.findMany({
|
||||
where: activeOnly ? { isActive: true } : undefined,
|
||||
orderBy: { name: "asc" },
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user