Files
NetForge/src/lib/services/ticket-category-service.ts
kevin-asprec 74d26d92f0 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
2026-03-05 07:40:10 +08:00

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" },
});
}