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