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:
kevin-asprec
2026-03-05 17:35:25 +08:00
parent 2d5b9ca2aa
commit 3f44f5c953
5 changed files with 510 additions and 0 deletions

View 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",
});
});

View 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);
}

View 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);
}

View 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 });
}
});

View 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();
});
});