diff --git a/src/app/api/ticket-categories/[id]/route.ts b/src/app/api/ticket-categories/[id]/route.ts new file mode 100644 index 0000000..2bcba7c --- /dev/null +++ b/src/app/api/ticket-categories/[id]/route.ts @@ -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; + + 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); +} diff --git a/src/app/api/ticket-categories/route.ts b/src/app/api/ticket-categories/route.ts new file mode 100644 index 0000000..4054d67 --- /dev/null +++ b/src/app/api/ticket-categories/route.ts @@ -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; + + 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 }); + } + } +); diff --git a/src/app/api/tickets/[id]/route.ts b/src/app/api/tickets/[id]/route.ts new file mode 100644 index 0000000..4239fc5 --- /dev/null +++ b/src/app/api/tickets/[id]/route.ts @@ -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; + + 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); +} diff --git a/src/app/api/tickets/[id]/status/route.ts b/src/app/api/tickets/[id]/status/route.ts new file mode 100644 index 0000000..5b44eee --- /dev/null +++ b/src/app/api/tickets/[id]/status/route.ts @@ -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; + + 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); +} diff --git a/src/app/api/tickets/route.ts b/src/app/api/tickets/route.ts new file mode 100644 index 0000000..8308448 --- /dev/null +++ b/src/app/api/tickets/route.ts @@ -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; + + 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 }); + } + } +); diff --git a/src/lib/__tests__/ticket-service.test.ts b/src/lib/__tests__/ticket-service.test.ts new file mode 100644 index 0000000..2827029 --- /dev/null +++ b/src/lib/__tests__/ticket-service.test.ts @@ -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); + }); +}); diff --git a/src/lib/services/ticket-category-service.ts b/src/lib/services/ticket-category-service.ts new file mode 100644 index 0000000..fc827ef --- /dev/null +++ b/src/lib/services/ticket-category-service.ts @@ -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 = {}; + 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" }, + }); +} diff --git a/src/lib/services/ticket-service.ts b/src/lib/services/ticket-service.ts new file mode 100644 index 0000000..397666b --- /dev/null +++ b/src/lib/services/ticket-service.ts @@ -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.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 { + 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 = {}; + 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 = {}; + 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 = { 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 { + 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); +}