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

View File

@@ -0,0 +1,551 @@
/**
* Ticket System Integration Tests
*
* Tests the full ticket lifecycle:
* - Default categories seeded on tenant creation (6 categories)
* - createCategory adds a new category
* - updateCategory deactivates a category
* - createTicket with valid category succeeds (TKT-0001)
* - createTicket with deactivated category throws
* - Second ticket gets TKT-0002
* - transitionTicketStatus OPEN -> CLOSED succeeds
* - transitionTicketStatus CLOSED -> OPEN throws (terminal state)
* - transitionTicketStatus OPEN -> RESOLVED throws (invalid)
* - resolveTicket is idempotent (calling on RESOLVED ticket does not throw)
* - listTickets with status filter returns correct subset
* - Cross-tenant isolation (Tenant B cannot see Tenant A tickets)
*
* These tests require a live PostgreSQL database connection.
*
* CLEANUP ORDER:
* tickets -> ticketCategories -> subscribers -> servicePlans ->
* tenantSettings -> accountingPeriods -> accounts -> users -> tenant
*/
import { prisma } from "@/lib/prisma";
import { withTenantContext } from "@/lib/prisma-tenant";
import { createTenant } from "@/lib/tenant";
import {
createTicket,
updateTicket,
getTicket,
listTickets,
transitionTicketStatus,
resolveTicket,
} from "@/lib/services/ticket-service";
import {
createCategory,
updateCategory,
listCategories,
} from "@/lib/services/ticket-category-service";
import { TicketPriority, TicketSource, TicketStatus } from "@prisma/client";
// ---------------------------------------------------------------------------
// Shared test state
// ---------------------------------------------------------------------------
const TS = Date.now();
let tenantAId: string;
let tenantBId: string;
let adminUserAId: string;
let adminUserBId: string;
// ---------------------------------------------------------------------------
// Setup helpers
// ---------------------------------------------------------------------------
function tA() {
return withTenantContext(tenantAId);
}
function tB() {
return withTenantContext(tenantBId);
}
// ---------------------------------------------------------------------------
// Before all: provision two tenants
// ---------------------------------------------------------------------------
beforeAll(async () => {
// Tenant A
const resultA = await createTenant({
businessName: `Ticket ISP A ${TS}`,
ownerFirstName: "Alice",
ownerLastName: "Admin",
ownerEmail: `ticket-admin-a-${TS}@test.com`,
password: "password123",
});
tenantAId = resultA.tenant.id;
adminUserAId = resultA.user.id;
// Tenant B
const resultB = await createTenant({
businessName: `Ticket ISP B ${TS}`,
ownerFirstName: "Bob",
ownerLastName: "Admin",
ownerEmail: `ticket-admin-b-${TS}@test.com`,
password: "password123",
});
tenantBId = resultB.tenant.id;
adminUserBId = resultB.user.id;
});
// ---------------------------------------------------------------------------
// After all: cleanup in dependency order
// ---------------------------------------------------------------------------
afterAll(async () => {
// Delete tickets first
await prisma.ticket.deleteMany({ where: { tenantId: { in: [tenantAId, tenantBId] } } });
// Then ticketCategories (seeded + any created in tests)
await prisma.ticketCategory.deleteMany({ where: { tenantId: { in: [tenantAId, tenantBId] } } });
// Then subscribers (none created in these tests, but safe to call)
await prisma.subscriber.deleteMany({ where: { tenantId: { in: [tenantAId, tenantBId] } } });
// Service plans
await prisma.servicePlan.deleteMany({ where: { tenantId: { in: [tenantAId, tenantBId] } } });
// Tenant settings
await prisma.tenantSettings.deleteMany({ where: { tenantId: { in: [tenantAId, tenantBId] } } });
// Accounting periods
await prisma.accountingPeriod.deleteMany({ where: { tenantId: { in: [tenantAId, tenantBId] } } });
// Journal entry lines, then entries
await prisma.journalEntryLine.deleteMany({ where: { tenantId: { in: [tenantAId, tenantBId] } } });
await prisma.journalEntry.updateMany({
where: { tenantId: { in: [tenantAId, tenantBId] } },
data: { reversesEntryId: null },
});
await prisma.journalEntry.deleteMany({ where: { tenantId: { in: [tenantAId, tenantBId] } } });
// Chart of accounts
await prisma.account.deleteMany({ where: { tenantId: { in: [tenantAId, tenantBId] } } });
// Users then tenants
await prisma.user.deleteMany({ where: { tenantId: { in: [tenantAId, tenantBId] } } });
await prisma.tenant.deleteMany({ where: { id: { in: [tenantAId, tenantBId] } } });
});
// ---------------------------------------------------------------------------
// Category tests
// ---------------------------------------------------------------------------
describe("Default ticket categories", () => {
it("seeds 6 default categories on tenant creation", async () => {
const categories = await listCategories(tA());
expect(categories).toHaveLength(6);
const names = categories.map((c: { name: string }) => c.name);
expect(names).toContain("No Connection");
expect(names).toContain("Slow Speed");
expect(names).toContain("Billing Inquiry");
expect(names).toContain("New Installation");
expect(names).toContain("Equipment Issue");
expect(names).toContain("Other");
});
it("all default categories start as active", async () => {
const categories = await listCategories(tA());
const allActive = categories.every((c: { isActive: boolean }) => c.isActive);
expect(allActive).toBe(true);
});
});
describe("createCategory", () => {
it("creates a new category successfully", async () => {
const category = await createCategory(tA(), tenantAId, {
name: `Custom Category ${TS}`,
description: "A custom test category",
});
expect(category.id).toBeTruthy();
expect(category.name).toBe(`Custom Category ${TS}`);
expect(category.description).toBe("A custom test category");
expect(category.isActive).toBe(true);
expect(category.tenantId).toBe(tenantAId);
});
it("throws on empty name", async () => {
await expect(
createCategory(tA(), tenantAId, { name: "" })
).rejects.toThrow("Category name is required");
});
it("throws on duplicate category name within tenant", async () => {
const name = `Duplicate Cat ${TS}`;
await createCategory(tA(), tenantAId, { name });
await expect(
createCategory(tA(), tenantAId, { name })
).rejects.toThrow(/already exists/);
});
});
describe("updateCategory", () => {
it("deactivates a category", async () => {
const category = await createCategory(tA(), tenantAId, {
name: `To Deactivate ${TS}`,
});
const updated = await updateCategory(tA(), category.id, { isActive: false });
expect(updated.isActive).toBe(false);
});
it("renames a category", async () => {
const category = await createCategory(tA(), tenantAId, {
name: `Old Name ${TS}`,
});
const newName = `New Name ${TS}`;
const updated = await updateCategory(tA(), category.id, { name: newName });
expect(updated.name).toBe(newName);
});
it("throws if category not found", async () => {
await expect(
updateCategory(tA(), "non-existent-id", { isActive: false })
).rejects.toThrow(/not found/);
});
});
describe("listCategories", () => {
it("returns all categories by default", async () => {
// Deactivate one category for this tenant to test filtering
const allCats = await listCategories(tA());
const firstActive = allCats.find((c: { isActive: boolean }) => c.isActive);
if (firstActive) {
await updateCategory(tA(), firstActive.id, { isActive: false });
}
const allAfter = await listCategories(tA());
const hasInactive = allAfter.some((c: { isActive: boolean }) => !c.isActive);
expect(hasInactive).toBe(true);
});
it("filters to active only when activeOnly=true", async () => {
const active = await listCategories(tA(), { activeOnly: true });
const allActive = active.every((c: { isActive: boolean }) => c.isActive);
expect(allActive).toBe(true);
});
});
// ---------------------------------------------------------------------------
// Ticket creation tests
// ---------------------------------------------------------------------------
let activeCategoryId: string;
describe("createTicket", () => {
beforeAll(async () => {
// Get or create a fresh active category for ticket creation tests
const cats = await listCategories(tA(), { activeOnly: true });
activeCategoryId = cats[0]?.id;
});
it("creates a ticket with TKT-0001 number", async () => {
const ticket = await createTicket(tA(), tenantAId, {
subject: "Internet is down",
description: "No connectivity since this morning",
categoryId: activeCategoryId,
createdById: adminUserAId,
});
expect(ticket.ticketNumber).toBe("TKT-0001");
expect(ticket.subject).toBe("Internet is down");
expect(ticket.status).toBe(TicketStatus.OPEN);
expect(ticket.priority).toBe(TicketPriority.MEDIUM);
expect(ticket.source).toBe(TicketSource.STAFF);
expect(ticket.tenantId).toBe(tenantAId);
});
it("creates a second ticket with TKT-0002", async () => {
const ticket = await createTicket(tA(), tenantAId, {
subject: "Slow speed",
description: "Getting 1 Mbps on 50 Mbps plan",
categoryId: activeCategoryId,
priority: TicketPriority.HIGH,
createdById: adminUserAId,
});
expect(ticket.ticketNumber).toBe("TKT-0002");
expect(ticket.priority).toBe(TicketPriority.HIGH);
});
it("throws when creating ticket with deactivated category", async () => {
// Create and deactivate a category
const cat = await createCategory(tA(), tenantAId, {
name: `Inactive Cat ${TS}`,
});
await updateCategory(tA(), cat.id, { isActive: false });
await expect(
createTicket(tA(), tenantAId, {
subject: "Test ticket",
description: "Should fail",
categoryId: cat.id,
createdById: adminUserAId,
})
).rejects.toThrow(/deactivated/);
});
it("throws when creating ticket with non-existent category", async () => {
await expect(
createTicket(tA(), tenantAId, {
subject: "Test ticket",
description: "Should fail",
categoryId: "non-existent-category-id",
createdById: adminUserAId,
})
).rejects.toThrow(/Category not found/);
});
});
// ---------------------------------------------------------------------------
// Status transition tests
// ---------------------------------------------------------------------------
describe("transitionTicketStatus", () => {
let ticketId: string;
beforeAll(async () => {
const cats = await listCategories(tA(), { activeOnly: true });
const catId = cats[0]?.id;
const ticket = await createTicket(tA(), tenantAId, {
subject: "Transition test ticket",
description: "For testing status transitions",
categoryId: catId,
createdById: adminUserAId,
});
ticketId = ticket.id;
});
it("transitions OPEN -> ASSIGNED", async () => {
const updated = await transitionTicketStatus(tA(), ticketId, TicketStatus.ASSIGNED);
expect(updated.status).toBe(TicketStatus.ASSIGNED);
});
it("transitions ASSIGNED -> RESOLVED and sets resolvedAt", async () => {
const updated = await transitionTicketStatus(tA(), ticketId, TicketStatus.RESOLVED);
expect(updated.status).toBe(TicketStatus.RESOLVED);
expect(updated.resolvedAt).toBeTruthy();
});
it("transitions RESOLVED -> CLOSED and sets closedAt", async () => {
const updated = await transitionTicketStatus(tA(), ticketId, TicketStatus.CLOSED);
expect(updated.status).toBe(TicketStatus.CLOSED);
expect(updated.closedAt).toBeTruthy();
});
it("throws when transitioning from CLOSED (terminal state)", async () => {
await expect(
transitionTicketStatus(tA(), ticketId, TicketStatus.OPEN)
).rejects.toThrow(/terminal state/);
});
});
describe("transitionTicketStatus - invalid transitions", () => {
let ticketId: string;
beforeAll(async () => {
const cats = await listCategories(tA(), { activeOnly: true });
const catId = cats[0]?.id;
const ticket = await createTicket(tA(), tenantAId, {
subject: "Invalid transition test",
description: "Testing invalid transitions",
categoryId: catId,
createdById: adminUserAId,
});
ticketId = ticket.id;
});
it("throws OPEN -> RESOLVED (must go through ASSIGNED first)", async () => {
await expect(
transitionTicketStatus(tA(), ticketId, TicketStatus.RESOLVED)
).rejects.toThrow(/Invalid status transition/);
});
});
// ---------------------------------------------------------------------------
// resolveTicket idempotency test
// ---------------------------------------------------------------------------
describe("resolveTicket", () => {
it("is idempotent when called on an already-RESOLVED ticket", async () => {
const cats = await listCategories(tA(), { activeOnly: true });
const catId = cats[0]?.id;
const ticket = await createTicket(tA(), tenantAId, {
subject: "Idempotent resolve test",
description: "Testing idempotent resolve",
categoryId: catId,
createdById: adminUserAId,
});
// First get to ASSIGNED, then RESOLVED
await transitionTicketStatus(tA(), ticket.id, TicketStatus.ASSIGNED);
await resolveTicket(tA(), ticket.id);
// Call resolveTicket again — should not throw
await expect(resolveTicket(tA(), ticket.id)).resolves.toBeUndefined();
});
it("throws when called on a CLOSED ticket (cannot resolve a closed ticket)", async () => {
const cats = await listCategories(tA(), { activeOnly: true });
const catId = cats[0]?.id;
const ticket = await createTicket(tA(), tenantAId, {
subject: "Closed ticket resolve test",
description: "Testing resolve on closed ticket",
categoryId: catId,
createdById: adminUserAId,
});
// OPEN -> CLOSED
await transitionTicketStatus(tA(), ticket.id, TicketStatus.CLOSED);
// Try to resolve a CLOSED ticket — should throw (CLOSED -> RESOLVED is not valid)
await expect(resolveTicket(tA(), ticket.id)).rejects.toThrow(/Invalid status transition/);
});
});
// ---------------------------------------------------------------------------
// getTicket and updateTicket tests
// ---------------------------------------------------------------------------
describe("getTicket", () => {
it("returns ticket with related data", async () => {
const cats = await listCategories(tA(), { activeOnly: true });
const catId = cats[0]?.id;
const created = await createTicket(tA(), tenantAId, {
subject: "Get ticket test",
description: "Testing getTicket",
categoryId: catId,
createdById: adminUserAId,
});
const ticket = await getTicket(tA(), created.id);
expect(ticket).not.toBeNull();
expect(ticket.id).toBe(created.id);
expect(ticket.category).toBeTruthy();
expect(ticket.category.id).toBe(catId);
expect(ticket.createdBy).toBeTruthy();
expect(ticket.createdBy.id).toBe(adminUserAId);
});
it("returns null for non-existent ticket", async () => {
const ticket = await getTicket(tA(), "non-existent-id");
expect(ticket).toBeNull();
});
});
describe("updateTicket", () => {
it("updates ticket subject and notes", async () => {
const cats = await listCategories(tA(), { activeOnly: true });
const catId = cats[0]?.id;
const ticket = await createTicket(tA(), tenantAId, {
subject: "Original subject",
description: "Original description",
categoryId: catId,
createdById: adminUserAId,
});
const updated = await updateTicket(tA(), ticket.id, {
subject: "Updated subject",
notes: "Staff added internal notes",
});
expect(updated.subject).toBe("Updated subject");
expect(updated.notes).toBe("Staff added internal notes");
expect(updated.status).toBe(TicketStatus.OPEN); // Status unchanged
});
});
// ---------------------------------------------------------------------------
// listTickets with filter test
// ---------------------------------------------------------------------------
describe("listTickets", () => {
it("filters by status correctly", async () => {
const cats = await listCategories(tA(), { activeOnly: true });
const catId = cats[0]?.id;
// Create one OPEN and one CLOSED ticket
const openTicket = await createTicket(tA(), tenantAId, {
subject: "Filter test open",
description: "Open ticket for filter test",
categoryId: catId,
createdById: adminUserAId,
});
const closedTicket = await createTicket(tA(), tenantAId, {
subject: "Filter test closed",
description: "Closed ticket for filter test",
categoryId: catId,
createdById: adminUserAId,
});
await transitionTicketStatus(tA(), closedTicket.id, TicketStatus.CLOSED);
// List only OPEN tickets
const openResult = await listTickets(tA(), { status: TicketStatus.OPEN });
const openIds = openResult.tickets.map((t: { id: string }) => t.id);
expect(openIds).toContain(openTicket.id);
expect(openIds).not.toContain(closedTicket.id);
// List only CLOSED tickets
const closedResult = await listTickets(tA(), { status: TicketStatus.CLOSED });
const closedIds = closedResult.tickets.map((t: { id: string }) => t.id);
expect(closedIds).toContain(closedTicket.id);
expect(closedIds).not.toContain(openTicket.id);
});
it("returns total count with pagination", async () => {
const result = await listTickets(tA(), { limit: 2, page: 1 });
expect(typeof result.total).toBe("number");
expect(result.tickets.length).toBeLessThanOrEqual(2);
expect(result.page).toBe(1);
expect(result.limit).toBe(2);
});
});
// ---------------------------------------------------------------------------
// Cross-tenant isolation test
// ---------------------------------------------------------------------------
describe("Cross-tenant isolation", () => {
it("Tenant B cannot see Tenant A tickets", async () => {
const catsA = await listCategories(tA(), { activeOnly: true });
const catAId = catsA[0]?.id;
// Create a ticket in Tenant A
const ticketA = await createTicket(tA(), tenantAId, {
subject: "Isolation test ticket",
description: "Should not be visible to Tenant B",
categoryId: catAId,
createdById: adminUserAId,
});
// Tenant B tries to get the ticket (should return null — tenant-scoped)
const fromB = await getTicket(tB(), ticketA.id);
expect(fromB).toBeNull();
// Tenant B list should not include Tenant A's tickets
const listB = await listTickets(tB());
const bIds = listB.tickets.map((t: { id: string }) => t.id);
expect(bIds).not.toContain(ticketA.id);
});
it("Tenant B has its own seeded categories (separate from Tenant A)", async () => {
const catsA = await listCategories(tA());
const catsB = await listCategories(tB());
// Both have 6 default categories
// We add some extras in tests above to Tenant A, so just verify Tenant B has its own
expect(catsB.length).toBeGreaterThanOrEqual(6);
const bIds = catsB.map((c: { id: string }) => c.id);
const aIds = catsA.map((c: { id: string }) => c.id);
// No overlap between tenant category IDs (tenant isolation)
const overlap = bIds.filter((id: string) => aIds.includes(id));
expect(overlap).toHaveLength(0);
});
});

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

View File

@@ -0,0 +1,406 @@
/**
* TicketService — Ticket CRUD, sequential numbering, and status lifecycle management.
*
* ARCHITECTURE:
* Tickets are the intake mechanism for subscriber issues. They follow a strict
* lifecycle enforced by the VALID_TICKET_TRANSITIONS guard map:
*
* OPEN -> ASSIGNED (triggered by job order creation in 03-04, or manually)
* OPEN -> CLOSED (staff can close without assigning)
* ASSIGNED -> OPEN (staff can un-assign / revert)
* ASSIGNED -> RESOLVED (triggered by job order completion in 03-04, or manually)
* RESOLVED -> CLOSED (final closure)
* RESOLVED -> OPEN (reopen if issue recurs)
* CLOSED -> (terminal — no further transitions)
*
* resolveTicket() is idempotent: calling on an already-RESOLVED ticket is a no-op.
* This prevents race conditions when multiple job orders complete simultaneously
* (see RESEARCH.md pitfall 5).
*
* SEQUENTIAL NUMBERING:
* Ticket numbers are auto-generated as TKT-NNNN (e.g., TKT-0001, TKT-0042).
* Same pattern as invoiceNumber and entryNumber in earlier phases.
*/
import { TicketStatus, TicketPriority, TicketSource } from "@prisma/client";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type TenantPrismaClient = any;
// ---------------------------------------------------------------------------
// Status transition guard map
// ---------------------------------------------------------------------------
/**
* Valid transitions for each ticket status.
* CLOSED is terminal — no transitions allowed.
*/
export const VALID_TICKET_TRANSITIONS: Record<TicketStatus, TicketStatus[]> = {
[TicketStatus.OPEN]: [TicketStatus.ASSIGNED, TicketStatus.CLOSED],
[TicketStatus.ASSIGNED]: [TicketStatus.OPEN, TicketStatus.RESOLVED],
[TicketStatus.RESOLVED]: [TicketStatus.CLOSED, TicketStatus.OPEN],
[TicketStatus.CLOSED]: [],
};
// ---------------------------------------------------------------------------
// Input/output types
// ---------------------------------------------------------------------------
export interface CreateTicketInput {
subject: string;
description: string;
categoryId: string;
priority?: TicketPriority;
subscriberId?: string;
createdById: string;
source?: TicketSource;
}
export interface UpdateTicketInput {
subject?: string;
description?: string;
categoryId?: string;
priority?: TicketPriority;
notes?: string;
}
export interface ListTicketsOptions {
status?: TicketStatus;
categoryId?: string;
priority?: TicketPriority;
subscriberId?: string;
page?: number;
limit?: number;
}
// ---------------------------------------------------------------------------
// generateTicketNumber
// ---------------------------------------------------------------------------
/**
* Generate the next sequential ticket number for this tenant.
* Format: TKT-NNNN (e.g., TKT-0001, TKT-0042)
*
* Finds the most recently created ticket with a TKT- prefix,
* parses the last 4 digits, increments, and pads to 4 characters.
* Starts at TKT-0001 if no tickets exist.
*/
async function generateTicketNumber(tenantPrisma: TenantPrismaClient): Promise<string> {
const lastTicket = await tenantPrisma.ticket.findFirst({
where: { ticketNumber: { startsWith: "TKT-" } },
orderBy: { ticketNumber: "desc" },
select: { ticketNumber: true },
});
if (!lastTicket) {
return "TKT-0001";
}
const lastNum = parseInt(lastTicket.ticketNumber.replace("TKT-", ""), 10);
const nextNum = isNaN(lastNum) ? 1 : lastNum + 1;
return `TKT-${String(nextNum).padStart(4, "0")}`;
}
// ---------------------------------------------------------------------------
// createTicket
// ---------------------------------------------------------------------------
/**
* Create a new support ticket.
*
* Validates that the category exists and is active before creation.
* Generates a sequential ticket number (TKT-NNNN).
* New tickets always start in OPEN status.
*
* @throws Error if category not found or deactivated
*/
export async function createTicket(
tenantPrisma: TenantPrismaClient,
tenantId: string,
input: CreateTicketInput
) {
const {
subject,
description,
categoryId,
priority = TicketPriority.MEDIUM,
subscriberId,
createdById,
source = TicketSource.STAFF,
} = input;
if (!subject || !subject.trim()) {
throw new Error("Ticket subject is required");
}
if (!description || !description.trim()) {
throw new Error("Ticket description is required");
}
// Validate category exists and is active
const category = await tenantPrisma.ticketCategory.findFirst({
where: { id: categoryId },
select: { id: true, isActive: true, name: true },
});
if (!category) {
throw new Error(`Category not found: ${categoryId}`);
}
if (!category.isActive) {
throw new Error(
`Category "${category.name}" is deactivated and cannot be used for new tickets`
);
}
const ticketNumber = await generateTicketNumber(tenantPrisma);
return tenantPrisma.ticket.create({
data: {
tenantId,
ticketNumber,
subject: subject.trim(),
description: description.trim(),
categoryId,
priority,
status: TicketStatus.OPEN,
source,
subscriberId: subscriberId ?? null,
createdById,
},
include: {
category: true,
subscriber: true,
createdBy: {
select: { id: true, firstName: true, lastName: true, email: true },
},
},
});
}
// ---------------------------------------------------------------------------
// updateTicket
// ---------------------------------------------------------------------------
/**
* Update ticket metadata (subject, description, category, priority, notes).
* Does NOT change ticket status — use transitionTicketStatus for that.
*
* @throws Error if ticket not found, or new category is deactivated
*/
export async function updateTicket(
tenantPrisma: TenantPrismaClient,
ticketId: string,
input: UpdateTicketInput
) {
const { subject, description, categoryId, priority, notes } = input;
const data: Record<string, unknown> = {};
if (subject !== undefined) data.subject = subject.trim();
if (description !== undefined) data.description = description.trim();
if (priority !== undefined) data.priority = priority;
if (notes !== undefined) data.notes = notes.trim() || null;
if (categoryId !== undefined) {
const category = await tenantPrisma.ticketCategory.findFirst({
where: { id: categoryId },
select: { id: true, isActive: true, name: true },
});
if (!category) {
throw new Error(`Category not found: ${categoryId}`);
}
if (!category.isActive) {
throw new Error(
`Category "${category.name}" is deactivated and cannot be used for new tickets`
);
}
data.categoryId = categoryId;
}
try {
return await tenantPrisma.ticket.update({
where: { id: ticketId },
data,
include: {
category: true,
subscriber: true,
createdBy: {
select: { id: true, firstName: true, lastName: true, email: true },
},
},
});
} 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(`Ticket not found: ${ticketId}`);
}
throw err;
}
}
// ---------------------------------------------------------------------------
// getTicket
// ---------------------------------------------------------------------------
/**
* Get a single ticket by ID, including related data.
*
* Returns null if ticket not found in tenant scope.
*/
export async function getTicket(
tenantPrisma: TenantPrismaClient,
ticketId: string
) {
return tenantPrisma.ticket.findFirst({
where: { id: ticketId },
include: {
category: true,
subscriber: true,
createdBy: {
select: { id: true, firstName: true, lastName: true, email: true },
},
},
});
}
// ---------------------------------------------------------------------------
// listTickets
// ---------------------------------------------------------------------------
/**
* List tickets with optional filters and pagination.
*
* Includes category and subscriber for each ticket.
* Ordered by createdAt descending (newest first).
*/
export async function listTickets(
tenantPrisma: TenantPrismaClient,
options: ListTicketsOptions = {}
) {
const { status, categoryId, priority, subscriberId, page = 1, limit = 20 } = options;
const skip = (page - 1) * limit;
const where: Record<string, unknown> = {};
if (status !== undefined) where.status = status;
if (categoryId !== undefined) where.categoryId = categoryId;
if (priority !== undefined) where.priority = priority;
if (subscriberId !== undefined) where.subscriberId = subscriberId;
const [tickets, total] = await Promise.all([
tenantPrisma.ticket.findMany({
where,
include: {
category: true,
subscriber: {
select: { id: true, accountNumber: true, firstName: true, lastName: true },
},
},
orderBy: { createdAt: "desc" },
skip,
take: limit,
}),
tenantPrisma.ticket.count({ where }),
]);
return { tickets, total, page, limit };
}
// ---------------------------------------------------------------------------
// transitionTicketStatus
// ---------------------------------------------------------------------------
/**
* Transition a ticket to a new status, enforcing the guard map.
*
* Valid transitions:
* OPEN -> ASSIGNED | CLOSED
* ASSIGNED -> OPEN | RESOLVED
* RESOLVED -> CLOSED | OPEN
* CLOSED -> (none — terminal)
*
* Sets resolvedAt on transition to RESOLVED.
* Sets closedAt on transition to CLOSED.
*
* @throws Error if ticket not found, or transition is invalid
*/
export async function transitionTicketStatus(
tenantPrisma: TenantPrismaClient,
ticketId: string,
newStatus: TicketStatus
) {
const ticket = await tenantPrisma.ticket.findFirst({
where: { id: ticketId },
select: { id: true, status: true, ticketNumber: true },
});
if (!ticket) {
throw new Error(`Ticket not found: ${ticketId}`);
}
const currentStatus = ticket.status as TicketStatus;
const allowedTransitions = VALID_TICKET_TRANSITIONS[currentStatus];
if (!allowedTransitions.includes(newStatus)) {
throw new Error(
`Invalid status transition: ${currentStatus} -> ${newStatus}. ` +
`Allowed: ${allowedTransitions.length > 0 ? allowedTransitions.join(", ") : "none (terminal state)"}`
);
}
const data: Record<string, unknown> = { status: newStatus };
if (newStatus === TicketStatus.RESOLVED) {
data.resolvedAt = new Date();
}
if (newStatus === TicketStatus.CLOSED) {
data.closedAt = new Date();
}
return tenantPrisma.ticket.update({
where: { id: ticketId },
data,
include: {
category: true,
subscriber: true,
createdBy: {
select: { id: true, firstName: true, lastName: true, email: true },
},
},
});
}
// ---------------------------------------------------------------------------
// resolveTicket (idempotent)
// ---------------------------------------------------------------------------
/**
* Mark a ticket as RESOLVED. Idempotent: if already RESOLVED, returns silently.
*
* This prevents race conditions when multiple job orders complete simultaneously
* and each triggers auto-resolve — only the first transition fires, the rest no-op.
*
* @throws Error if ticket not found, or transition from current status to RESOLVED is invalid
* (except when already RESOLVED — that is silently ignored)
*/
export async function resolveTicket(
tenantPrisma: TenantPrismaClient,
ticketId: string
): Promise<void> {
const ticket = await tenantPrisma.ticket.findFirst({
where: { id: ticketId },
select: { id: true, status: true },
});
if (!ticket) {
throw new Error(`Ticket not found: ${ticketId}`);
}
// Idempotent: already resolved, nothing to do
if (ticket.status === TicketStatus.RESOLVED) {
return;
}
await transitionTicketStatus(tenantPrisma, ticketId, TicketStatus.RESOLVED);
}