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