diff --git a/prisma/schema.prisma b/prisma/schema.prisma index b2b9764..ce65e95 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -272,6 +272,8 @@ model TenantSettings { autoSuspendDays Int @default(30) /// Days before billing date to generate prepaid invoices (default 7) prepaidLeadDays Int @default(7) + /// Freeform payment instructions shown on the portal "pay online" page (e.g., GCash, bank details) + paymentInstructions String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -448,6 +450,8 @@ model User { createdExpenses Expense[] @relation("ExpenseCreatedBy") /// Expenses approved by this user approvedExpenses Expense[] @relation("ExpenseApprovedBy") + /// Ticket comments authored by this user + ticketComments TicketComment[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -695,6 +699,27 @@ model Ticket { @@index([subscriberId]) jobOrders JobOrder[] + comments TicketComment[] +} + +/// A TicketComment is a message in a ticket conversation thread. +/// Subscribers and staff can both add comments to open tickets. +/// Comments are append-only — no edits or deletes. +model TicketComment { + id String @id @default(uuid()) + tenantId String + ticketId String + ticket Ticket @relation(fields: [ticketId], references: [id]) + /// Who posted this comment — can be subscriber (CLIENT) or staff + authorId String + author User @relation(fields: [authorId], references: [id]) + /// For portal comments, link to the subscriber directly + subscriberId String? + message String + createdAt DateTime @default(now()) + + @@index([tenantId]) + @@index([ticketId]) } /// A Collection records cash received from a subscriber by a field collector. diff --git a/src/lib/services/portal-ticket-service.ts b/src/lib/services/portal-ticket-service.ts new file mode 100644 index 0000000..547a1f2 --- /dev/null +++ b/src/lib/services/portal-ticket-service.ts @@ -0,0 +1,283 @@ +/** + * PortalTicketService -- Subscriber-scoped ticket creation and conversation threads. + * + * ARCHITECTURE: + * Portal subscribers authenticate via Subscriber (not User). The Ticket model requires + * createdById -> User.id. To bridge this, ensurePortalUser() lazily creates a shadow + * User record with CLIENT role when a subscriber first creates a ticket. + * + * Ticket creation delegates to the existing createTicket() from ticket-service.ts + * with source=SUBSCRIBER, ensuring portal tickets appear in the staff ticket queue. + * + * Provides: createPortalTicket, listPortalTickets, getPortalTicket, addTicketComment, ensurePortalUser + */ + +import { prisma } from "@/lib/prisma"; +import { createTicket } from "@/lib/services/ticket-service"; +import { TicketSource, TicketStatus, Role } from "@prisma/client"; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type TenantPrismaClient = any; + +// --------------------------------------------------------------------------- +// ensurePortalUser +// --------------------------------------------------------------------------- + +/** + * Ensure a User record exists for a portal subscriber. + * + * Portal auth uses Subscriber (not User) for authentication. But the Ticket model + * requires createdById -> User.id. This function lazily creates a shadow User record + * with CLIENT role when a subscriber first needs one. + * + * Convention: portal user email = `portal-{accountNumber}@portal.local` + * This avoids collision with staff users who authenticate by real email. + * + * Returns the User.id for use in ticket creation and comments. + */ +export async function ensurePortalUser( + tenantId: string, + subscriber: { + id: string; + accountNumber: string; + firstName: string; + lastName: string; + passwordHash: string | null; + } +): Promise { + const portalEmail = `portal-${subscriber.accountNumber}@portal.local`; + + // Try to find existing portal user + const existing = await prisma.user.findFirst({ + where: { email: portalEmail, tenantId }, + select: { id: true }, + }); + + if (existing) { + return existing.id; + } + + // Create shadow User for this subscriber + const user = await prisma.user.create({ + data: { + email: portalEmail, + passwordHash: subscriber.passwordHash || "portal-no-direct-login", + firstName: subscriber.firstName, + lastName: subscriber.lastName, + tenantId, + roles: [Role.CLIENT], + isActive: true, + }, + }); + + return user.id; +} + +// --------------------------------------------------------------------------- +// createPortalTicket +// --------------------------------------------------------------------------- + +export interface CreatePortalTicketInput { + categoryId: string; + subject: string; + description: string; +} + +/** + * Create a support ticket from the subscriber portal. + * + * Delegates to the existing createTicket() from ticket-service.ts with source=SUBSCRIBER. + * Ensures a shadow User record exists for the subscriber (needed for createdById FK). + * Sets subscriberId so the ticket appears in both the portal and staff views. + */ +export async function createPortalTicket( + db: TenantPrismaClient, + tenantId: string, + subscriberId: string, + input: CreatePortalTicketInput +) { + // Look up subscriber to get their info for ensurePortalUser + const subscriber = await prisma.subscriber.findFirst({ + where: { id: subscriberId, tenantId }, + select: { + id: true, + accountNumber: true, + firstName: true, + lastName: true, + passwordHash: true, + }, + }); + + if (!subscriber) { + throw new Error("Subscriber not found"); + } + + // Ensure a User record exists for the subscriber + const userId = await ensurePortalUser(tenantId, subscriber); + + // Delegate to existing ticket creation service + return createTicket(db, tenantId, { + subject: input.subject, + description: input.description, + categoryId: input.categoryId, + subscriberId, + createdById: userId, + source: TicketSource.SUBSCRIBER, + }); +} + +// --------------------------------------------------------------------------- +// listPortalTickets +// --------------------------------------------------------------------------- + +export interface ListPortalTicketsOptions { + page?: number; + limit?: number; +} + +/** + * List tickets for a specific subscriber, ordered by createdAt DESC. + * Includes category name and latest comment for summary display. + */ +export async function listPortalTickets( + db: TenantPrismaClient, + subscriberId: string, + options: ListPortalTicketsOptions = {} +) { + const { page = 1, limit = 20 } = options; + const skip = (page - 1) * limit; + + const [tickets, total] = await Promise.all([ + db.ticket.findMany({ + where: { subscriberId }, + include: { + category: { select: { id: true, name: true } }, + comments: { + orderBy: { createdAt: "desc" }, + take: 1, + select: { + id: true, + message: true, + createdAt: true, + author: { select: { firstName: true, lastName: true } }, + }, + }, + }, + orderBy: { createdAt: "desc" }, + skip, + take: limit, + }), + db.ticket.count({ where: { subscriberId } }), + ]); + + return { tickets, total, page, limit }; +} + +// --------------------------------------------------------------------------- +// getPortalTicket +// --------------------------------------------------------------------------- + +/** + * Get a single ticket with full conversation thread. + * Verifies ticket.subscriberId === subscriberId (security check). + * Returns ticket with all comments ordered by createdAt ASC (chronological). + */ +export async function getPortalTicket( + db: TenantPrismaClient, + subscriberId: string, + ticketId: string +) { + const ticket = await db.ticket.findFirst({ + where: { id: ticketId }, + include: { + category: { select: { id: true, name: true } }, + comments: { + orderBy: { createdAt: "asc" }, + select: { + id: true, + message: true, + subscriberId: true, + createdAt: true, + author: { select: { id: true, firstName: true, lastName: true } }, + }, + }, + }, + }); + + if (!ticket) { + return null; + } + + // Security: subscriber can only view their own tickets + if (ticket.subscriberId !== subscriberId) { + return null; + } + + return ticket; +} + +// --------------------------------------------------------------------------- +// addTicketComment +// --------------------------------------------------------------------------- + +/** + * Add a comment to a ticket conversation thread. + * + * Security checks: + * - Ticket must exist and belong to the subscriber + * - Ticket must not be CLOSED (no comments on closed tickets) + * + * @throws Error if ticket not found, not owned by subscriber, or closed + */ +export async function addTicketComment( + db: TenantPrismaClient, + tenantId: string, + subscriberId: string, + ticketId: string, + message: string +) { + // Verify ticket exists and belongs to subscriber + const ticket = await db.ticket.findFirst({ + where: { id: ticketId }, + select: { id: true, subscriberId: true, status: true, tenantId: true }, + }); + + if (!ticket || ticket.subscriberId !== subscriberId) { + throw new Error("Ticket not found"); + } + + if (ticket.status === TicketStatus.CLOSED) { + throw new Error("Cannot comment on a closed ticket"); + } + + // Get the subscriber's shadow User ID for the authorId FK + const subscriber = await prisma.subscriber.findFirst({ + where: { id: subscriberId, tenantId }, + select: { + id: true, + accountNumber: true, + firstName: true, + lastName: true, + passwordHash: true, + }, + }); + + if (!subscriber) { + throw new Error("Subscriber not found"); + } + + const userId = await ensurePortalUser(tenantId, subscriber); + + return prisma.ticketComment.create({ + data: { + tenantId, + ticketId, + authorId: userId, + subscriberId, + message: message.trim(), + }, + include: { + author: { select: { id: true, firstName: true, lastName: true } }, + }, + }); +}