- 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
122 lines
3.5 KiB
TypeScript
122 lines
3.5 KiB
TypeScript
/**
|
|
* 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" },
|
|
});
|
|
}
|