diff --git a/src/app/api/portal/payments/coming-soon/route.ts b/src/app/api/portal/payments/coming-soon/route.ts new file mode 100644 index 0000000..60fe851 --- /dev/null +++ b/src/app/api/portal/payments/coming-soon/route.ts @@ -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", + }); +}); diff --git a/src/app/api/portal/tickets/[id]/comments/route.ts b/src/app/api/portal/tickets/[id]/comments/route.ts new file mode 100644 index 0000000..7a87f26 --- /dev/null +++ b/src/app/api/portal/tickets/[id]/comments/route.ts @@ -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); +} diff --git a/src/app/api/portal/tickets/[id]/route.ts b/src/app/api/portal/tickets/[id]/route.ts new file mode 100644 index 0000000..83fa5d3 --- /dev/null +++ b/src/app/api/portal/tickets/[id]/route.ts @@ -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); +} diff --git a/src/app/api/portal/tickets/route.ts b/src/app/api/portal/tickets/route.ts new file mode 100644 index 0000000..595549f --- /dev/null +++ b/src/app/api/portal/tickets/route.ts @@ -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 }); + } +}); diff --git a/src/lib/__tests__/portal-ticket-service.test.ts b/src/lib/__tests__/portal-ticket-service.test.ts new file mode 100644 index 0000000..918a116 --- /dev/null +++ b/src/lib/__tests__/portal-ticket-service.test.ts @@ -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(); + }); +});