feat(05-03): portal ticket API routes, payment scaffold, and tests
- GET/POST /api/portal/tickets for listing and creating tickets - GET /api/portal/tickets/[id] for ticket detail with conversation - GET/POST /api/portal/tickets/[id]/comments for conversation threads - GET /api/portal/payments/coming-soon returns outstanding balance and payment instructions - 6 integration tests: SUBSCRIBER source, staff visibility, isolation, comments, closed rejection, thread ordering Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
42
src/app/api/portal/payments/coming-soon/route.ts
Normal file
42
src/app/api/portal/payments/coming-soon/route.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
/**
|
||||||
|
* GET /api/portal/payments/coming-soon
|
||||||
|
*
|
||||||
|
* Returns the subscriber's outstanding balance and payment instructions.
|
||||||
|
* Online payments are scaffolded -- actual payment gateway integration is deferred to v2.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { withPortalAuth } from "@/lib/middleware/portal-auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
export const GET = withPortalAuth(async (_req, { subscriberId, tenantId, tenantPrisma }) => {
|
||||||
|
// Get outstanding balance: sum of unpaid invoice amounts (totalAmount - amountPaid)
|
||||||
|
// for invoices that are SENT, PARTIAL, or OVERDUE
|
||||||
|
const invoices = await tenantPrisma.invoice.findMany({
|
||||||
|
where: {
|
||||||
|
subscriberId,
|
||||||
|
status: { in: ["SENT", "PARTIAL", "OVERDUE"] },
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
totalAmount: true,
|
||||||
|
amountPaid: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
let outstandingBalance = 0;
|
||||||
|
for (const inv of invoices) {
|
||||||
|
outstandingBalance += Number(inv.totalAmount) - Number(inv.amountPaid);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get tenant payment instructions
|
||||||
|
const settings = await prisma.tenantSettings.findFirst({
|
||||||
|
where: { tenantId },
|
||||||
|
select: { paymentInstructions: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
outstandingBalance: Math.round(outstandingBalance * 100) / 100,
|
||||||
|
paymentInstructions: settings?.paymentInstructions || null,
|
||||||
|
message: "Online payments coming soon",
|
||||||
|
});
|
||||||
|
});
|
||||||
61
src/app/api/portal/tickets/[id]/comments/route.ts
Normal file
61
src/app/api/portal/tickets/[id]/comments/route.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
/**
|
||||||
|
* GET /api/portal/tickets/[id]/comments — List comments for a ticket
|
||||||
|
* POST /api/portal/tickets/[id]/comments — Add a comment to a ticket
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { withPortalAuth } from "@/lib/middleware/portal-auth";
|
||||||
|
import {
|
||||||
|
getPortalTicket,
|
||||||
|
addTicketComment,
|
||||||
|
} from "@/lib/services/portal-ticket-service";
|
||||||
|
|
||||||
|
export function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
return withPortalAuth(async (_req, { subscriberId, tenantPrisma }) => {
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
const ticket = await getPortalTicket(tenantPrisma, subscriberId, id);
|
||||||
|
|
||||||
|
if (!ticket) {
|
||||||
|
return NextResponse.json({ error: "Ticket not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ comments: ticket.comments });
|
||||||
|
})(req);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
return withPortalAuth(async (innerReq, { subscriberId, tenantId, tenantPrisma }) => {
|
||||||
|
const { id } = await params;
|
||||||
|
const body = await innerReq.json();
|
||||||
|
const { message } = body;
|
||||||
|
|
||||||
|
if (!message || !message.trim()) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "message is required" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const comment = await addTicketComment(
|
||||||
|
tenantPrisma,
|
||||||
|
tenantId,
|
||||||
|
subscriberId,
|
||||||
|
id,
|
||||||
|
message
|
||||||
|
);
|
||||||
|
|
||||||
|
return NextResponse.json(comment, { status: 201 });
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
if (msg.includes("not found")) {
|
||||||
|
return NextResponse.json({ error: msg }, { status: 404 });
|
||||||
|
}
|
||||||
|
if (msg.includes("closed")) {
|
||||||
|
return NextResponse.json({ error: msg }, { status: 400 });
|
||||||
|
}
|
||||||
|
return NextResponse.json({ error: msg }, { status: 400 });
|
||||||
|
}
|
||||||
|
})(req);
|
||||||
|
}
|
||||||
21
src/app/api/portal/tickets/[id]/route.ts
Normal file
21
src/app/api/portal/tickets/[id]/route.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
/**
|
||||||
|
* GET /api/portal/tickets/[id] — Get ticket detail with conversation thread
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { withPortalAuth } from "@/lib/middleware/portal-auth";
|
||||||
|
import { getPortalTicket } from "@/lib/services/portal-ticket-service";
|
||||||
|
|
||||||
|
export function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
return withPortalAuth(async (_req, { subscriberId, tenantPrisma }) => {
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
const ticket = await getPortalTicket(tenantPrisma, subscriberId, id);
|
||||||
|
|
||||||
|
if (!ticket) {
|
||||||
|
return NextResponse.json({ error: "Ticket not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(ticket);
|
||||||
|
})(req);
|
||||||
|
}
|
||||||
46
src/app/api/portal/tickets/route.ts
Normal file
46
src/app/api/portal/tickets/route.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
/**
|
||||||
|
* GET /api/portal/tickets — List subscriber's tickets (paginated)
|
||||||
|
* POST /api/portal/tickets — Create a new support ticket
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { withPortalAuth } from "@/lib/middleware/portal-auth";
|
||||||
|
import {
|
||||||
|
createPortalTicket,
|
||||||
|
listPortalTickets,
|
||||||
|
} from "@/lib/services/portal-ticket-service";
|
||||||
|
|
||||||
|
export const GET = withPortalAuth(async (req, { subscriberId, tenantPrisma }) => {
|
||||||
|
const { searchParams } = new URL(req.url);
|
||||||
|
const page = parseInt(searchParams.get("page") || "1", 10);
|
||||||
|
const limit = parseInt(searchParams.get("limit") || "20", 10);
|
||||||
|
|
||||||
|
const result = await listPortalTickets(tenantPrisma, subscriberId, { page, limit });
|
||||||
|
|
||||||
|
return NextResponse.json(result);
|
||||||
|
});
|
||||||
|
|
||||||
|
export const POST = withPortalAuth(async (req, { subscriberId, tenantId, tenantPrisma }) => {
|
||||||
|
const body = await req.json();
|
||||||
|
const { categoryId, subject, description } = body;
|
||||||
|
|
||||||
|
if (!categoryId || !subject || !description) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "categoryId, subject, and description are required" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ticket = await createPortalTicket(tenantPrisma, tenantId, subscriberId, {
|
||||||
|
categoryId,
|
||||||
|
subject,
|
||||||
|
description,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(ticket, { status: 201 });
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
return NextResponse.json({ error: message }, { status: 400 });
|
||||||
|
}
|
||||||
|
});
|
||||||
340
src/lib/__tests__/portal-ticket-service.test.ts
Normal file
340
src/lib/__tests__/portal-ticket-service.test.ts
Normal file
@@ -0,0 +1,340 @@
|
|||||||
|
/**
|
||||||
|
* Portal Ticket Service Integration Tests
|
||||||
|
*
|
||||||
|
* Tests subscriber-scoped ticket creation, conversation threads, and isolation:
|
||||||
|
* 1. createPortalTicket creates ticket with SUBSCRIBER source
|
||||||
|
* 2. createPortalTicket ticket appears in staff listTickets
|
||||||
|
* 3. listPortalTickets returns only subscriber's tickets
|
||||||
|
* 4. addTicketComment creates conversation entry
|
||||||
|
* 5. addTicketComment rejects on closed ticket
|
||||||
|
* 6. getPortalTicket returns ticket with full conversation thread
|
||||||
|
*
|
||||||
|
* CLEANUP ORDER:
|
||||||
|
* ticketComments -> tickets -> ticketCategories -> subscribers ->
|
||||||
|
* servicePlans -> tenantSettings -> accountingPeriods -> accounts ->
|
||||||
|
* users -> tenant
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { withTenantContext } from "@/lib/prisma-tenant";
|
||||||
|
import { seedChartOfAccounts } from "@/lib/accounting/seed-coa";
|
||||||
|
import {
|
||||||
|
createPortalTicket,
|
||||||
|
listPortalTickets,
|
||||||
|
getPortalTicket,
|
||||||
|
addTicketComment,
|
||||||
|
ensurePortalUser,
|
||||||
|
} from "@/lib/services/portal-ticket-service";
|
||||||
|
import { listTickets } from "@/lib/services/ticket-service";
|
||||||
|
import { BillingType, TenantStatus, TicketStatus } from "@prisma/client";
|
||||||
|
import bcrypt from "bcryptjs";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Shared test state
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const TS = Date.now();
|
||||||
|
|
||||||
|
let tenantId: string;
|
||||||
|
let planId: string;
|
||||||
|
let subscriberAId: string;
|
||||||
|
let subscriberBId: string;
|
||||||
|
let categoryId: string;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function db() {
|
||||||
|
return withTenantContext(tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Setup / Teardown
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
// Create tenant
|
||||||
|
const tenant = await prisma.tenant.create({
|
||||||
|
data: {
|
||||||
|
name: `PortalTicket Test ${TS}`,
|
||||||
|
slug: `portal-ticket-${TS}`,
|
||||||
|
ownerEmail: `portal-ticket-${TS}@test.example`,
|
||||||
|
status: TenantStatus.ACTIVE,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
tenantId = tenant.id;
|
||||||
|
|
||||||
|
// Seed COA (needed for createTenant convention)
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
await seedChartOfAccounts(tx as any, tenantId);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create tenant settings
|
||||||
|
await prisma.tenantSettings.create({
|
||||||
|
data: {
|
||||||
|
tenantId,
|
||||||
|
autoSuspendDays: 30,
|
||||||
|
prepaidLeadDays: 7,
|
||||||
|
paymentInstructions: "Send payment via GCash to 09171234567",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create service plan
|
||||||
|
const plan = await prisma.servicePlan.create({
|
||||||
|
data: {
|
||||||
|
tenantId,
|
||||||
|
name: `Ticket Plan ${TS}`,
|
||||||
|
speed: "50 Mbps",
|
||||||
|
monthlyPrice: 49.99,
|
||||||
|
billingType: BillingType.POSTPAID,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
planId = plan.id;
|
||||||
|
|
||||||
|
// Create subscriber A (with portal access)
|
||||||
|
const passwordHash = bcrypt.hashSync("subscriber-pass-123", 10);
|
||||||
|
const subA = await prisma.subscriber.create({
|
||||||
|
data: {
|
||||||
|
tenantId,
|
||||||
|
accountNumber: `TKT-A-${TS}`,
|
||||||
|
firstName: "Alice",
|
||||||
|
lastName: "Ticketer",
|
||||||
|
email: `alice-tkt-${TS}@subscriber.example`,
|
||||||
|
address: "123 Ticket St",
|
||||||
|
servicePlanId: planId,
|
||||||
|
status: "ACTIVE",
|
||||||
|
billingDay: 15,
|
||||||
|
passwordHash,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
subscriberAId = subA.id;
|
||||||
|
|
||||||
|
// Create subscriber B (for isolation tests)
|
||||||
|
const subB = await prisma.subscriber.create({
|
||||||
|
data: {
|
||||||
|
tenantId,
|
||||||
|
accountNumber: `TKT-B-${TS}`,
|
||||||
|
firstName: "Bob",
|
||||||
|
lastName: "Ticketer",
|
||||||
|
address: "456 Other St",
|
||||||
|
servicePlanId: planId,
|
||||||
|
status: "ACTIVE",
|
||||||
|
billingDay: 20,
|
||||||
|
passwordHash: bcrypt.hashSync("bob-pass-456", 10),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
subscriberBId = subB.id;
|
||||||
|
|
||||||
|
// Seed a ticket category (createTenant seeds defaults, but we need to get the ID)
|
||||||
|
const categories = await prisma.ticketCategory.findMany({
|
||||||
|
where: { tenantId },
|
||||||
|
take: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (categories.length > 0) {
|
||||||
|
categoryId = categories[0].id;
|
||||||
|
} else {
|
||||||
|
// Fallback: create one manually
|
||||||
|
const cat = await prisma.ticketCategory.create({
|
||||||
|
data: {
|
||||||
|
tenantId,
|
||||||
|
name: `Test Category ${TS}`,
|
||||||
|
description: "Test ticket category",
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
categoryId = cat.id;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
if (!tenantId) return;
|
||||||
|
|
||||||
|
// 1. Ticket comments
|
||||||
|
await prisma.ticketComment.deleteMany({ where: { tenantId } }).catch(() => {});
|
||||||
|
// 2. Job orders (tickets FK constraint)
|
||||||
|
await prisma.jobOrder.deleteMany({ where: { tenantId } }).catch(() => {});
|
||||||
|
// 3. Tickets
|
||||||
|
await prisma.ticket.deleteMany({ where: { tenantId } }).catch(() => {});
|
||||||
|
// 4. Ticket categories
|
||||||
|
await prisma.ticketCategory.deleteMany({ where: { tenantId } }).catch(() => {});
|
||||||
|
// 5. Subscribers
|
||||||
|
await prisma.subscriber.deleteMany({ where: { tenantId } }).catch(() => {});
|
||||||
|
// 6. Service plans
|
||||||
|
await prisma.servicePlan.deleteMany({ where: { tenantId } }).catch(() => {});
|
||||||
|
// 7. Tenant settings
|
||||||
|
await prisma.tenantSettings.deleteMany({ where: { tenantId } }).catch(() => {});
|
||||||
|
// 8. Accounting periods
|
||||||
|
await prisma.accountingPeriod.deleteMany({ where: { tenantId } }).catch(() => {});
|
||||||
|
// 9. Accounts
|
||||||
|
await prisma.account.deleteMany({ where: { tenantId } }).catch(() => {});
|
||||||
|
// 10. Users (including shadow portal users)
|
||||||
|
await prisma.user.deleteMany({ where: { tenantId } }).catch(() => {});
|
||||||
|
// 11. Tenant
|
||||||
|
await prisma.tenant.delete({ where: { id: tenantId } }).catch(() => {});
|
||||||
|
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// TESTS
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
describe("PortalTicketService", () => {
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// 1. createPortalTicket creates ticket with SUBSCRIBER source
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
it("createPortalTicket creates ticket with SUBSCRIBER source", async () => {
|
||||||
|
const ticket = await createPortalTicket(db(), tenantId, subscriberAId, {
|
||||||
|
categoryId,
|
||||||
|
subject: "My internet is slow",
|
||||||
|
description: "Speed test shows only 5 Mbps instead of 50 Mbps",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(ticket).toBeDefined();
|
||||||
|
expect(ticket.source).toBe("SUBSCRIBER");
|
||||||
|
expect(ticket.status).toBe("OPEN");
|
||||||
|
expect(ticket.subscriberId).toBe(subscriberAId);
|
||||||
|
expect(ticket.subject).toBe("My internet is slow");
|
||||||
|
expect(ticket.ticketNumber).toMatch(/^TKT-\d{4}$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// 2. createPortalTicket ticket appears in staff listTickets
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
it("createPortalTicket ticket appears in staff listTickets", async () => {
|
||||||
|
const ticket = await createPortalTicket(db(), tenantId, subscriberAId, {
|
||||||
|
categoryId,
|
||||||
|
subject: "Billing question",
|
||||||
|
description: "Why was I charged extra this month?",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Query via staff ticket service (no subscriber filter)
|
||||||
|
const staffResult = await listTickets(db());
|
||||||
|
const found = staffResult.tickets.find(
|
||||||
|
(t: { id: string }) => t.id === ticket.id
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(found).toBeDefined();
|
||||||
|
expect(found.source).toBe("SUBSCRIBER");
|
||||||
|
expect(found.subscriberId).toBe(subscriberAId);
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// 3. listPortalTickets returns only subscriber's tickets
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
it("listPortalTickets returns only subscriber's tickets", async () => {
|
||||||
|
// Create tickets for subscriber A
|
||||||
|
await createPortalTicket(db(), tenantId, subscriberAId, {
|
||||||
|
categoryId,
|
||||||
|
subject: "Alice ticket 1",
|
||||||
|
description: "Alice issue",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create ticket for subscriber B
|
||||||
|
await createPortalTicket(db(), tenantId, subscriberBId, {
|
||||||
|
categoryId,
|
||||||
|
subject: "Bob ticket 1",
|
||||||
|
description: "Bob issue",
|
||||||
|
});
|
||||||
|
|
||||||
|
// List for subscriber A
|
||||||
|
const resultA = await listPortalTickets(db(), subscriberAId);
|
||||||
|
for (const t of resultA.tickets) {
|
||||||
|
expect(t.subscriberId).toBe(subscriberAId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// List for subscriber B
|
||||||
|
const resultB = await listPortalTickets(db(), subscriberBId);
|
||||||
|
for (const t of resultB.tickets) {
|
||||||
|
expect(t.subscriberId).toBe(subscriberBId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscriber A should NOT see subscriber B's tickets
|
||||||
|
const aTicketSubjects = resultA.tickets.map((t: { subject: string }) => t.subject);
|
||||||
|
expect(aTicketSubjects).not.toContain("Bob ticket 1");
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// 4. addTicketComment creates conversation entry
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
it("addTicketComment creates conversation entry", async () => {
|
||||||
|
const ticket = await createPortalTicket(db(), tenantId, subscriberAId, {
|
||||||
|
categoryId,
|
||||||
|
subject: "Need help with router",
|
||||||
|
description: "Router keeps disconnecting",
|
||||||
|
});
|
||||||
|
|
||||||
|
const comment = await addTicketComment(
|
||||||
|
db(),
|
||||||
|
tenantId,
|
||||||
|
subscriberAId,
|
||||||
|
ticket.id,
|
||||||
|
"It happened again just now"
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(comment).toBeDefined();
|
||||||
|
expect(comment.message).toBe("It happened again just now");
|
||||||
|
expect(comment.subscriberId).toBe(subscriberAId);
|
||||||
|
expect(comment.author).toBeDefined();
|
||||||
|
expect(comment.author.firstName).toBe("Alice");
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// 5. addTicketComment rejects on closed ticket
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
it("addTicketComment rejects on closed ticket", async () => {
|
||||||
|
const ticket = await createPortalTicket(db(), tenantId, subscriberAId, {
|
||||||
|
categoryId,
|
||||||
|
subject: "Will be closed",
|
||||||
|
description: "This ticket will be closed",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close the ticket directly (simulating staff action)
|
||||||
|
await prisma.ticket.update({
|
||||||
|
where: { id: ticket.id },
|
||||||
|
data: { status: TicketStatus.CLOSED, closedAt: new Date() },
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
addTicketComment(db(), tenantId, subscriberAId, ticket.id, "Can I still comment?")
|
||||||
|
).rejects.toThrow("Cannot comment on a closed ticket");
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// 6. getPortalTicket returns ticket with full conversation thread
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
it("getPortalTicket returns ticket with full conversation thread", async () => {
|
||||||
|
const ticket = await createPortalTicket(db(), tenantId, subscriberAId, {
|
||||||
|
categoryId,
|
||||||
|
subject: "Thread test",
|
||||||
|
description: "Testing conversation thread",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add 3 comments
|
||||||
|
await addTicketComment(db(), tenantId, subscriberAId, ticket.id, "Comment 1");
|
||||||
|
await addTicketComment(db(), tenantId, subscriberAId, ticket.id, "Comment 2");
|
||||||
|
await addTicketComment(db(), tenantId, subscriberAId, ticket.id, "Comment 3");
|
||||||
|
|
||||||
|
const result = await getPortalTicket(db(), subscriberAId, ticket.id);
|
||||||
|
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result!.comments).toHaveLength(3);
|
||||||
|
|
||||||
|
// Verify chronological order (ASC)
|
||||||
|
const messages = result!.comments.map((c: { message: string }) => c.message);
|
||||||
|
expect(messages).toEqual(["Comment 1", "Comment 2", "Comment 3"]);
|
||||||
|
|
||||||
|
// Verify each comment has author info
|
||||||
|
for (const comment of result!.comments) {
|
||||||
|
expect(comment.author).toBeDefined();
|
||||||
|
expect(comment.author.firstName).toBe("Alice");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Security: subscriber B cannot see subscriber A's ticket
|
||||||
|
const crossCheck = await getPortalTicket(db(), subscriberBId, ticket.id);
|
||||||
|
expect(crossCheck).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user