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 });
|
||||
}
|
||||
}
|
||||
);
|
||||
551
src/lib/__tests__/ticket-service.test.ts
Normal file
551
src/lib/__tests__/ticket-service.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
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" },
|
||||
});
|
||||
}
|
||||
406
src/lib/services/ticket-service.ts
Normal file
406
src/lib/services/ticket-service.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user