feat(05-03): TicketComment model and portal ticket service
- Add TicketComment model for conversation threads (append-only) - Add paymentInstructions field to TenantSettings - Add ticketComments relation to User model - Create portal-ticket-service with ensurePortalUser shadow User pattern - Implements createPortalTicket, listPortalTickets, getPortalTicket, addTicketComment - Portal ticket creation delegates to existing createTicket with source=SUBSCRIBER Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -272,6 +272,8 @@ model TenantSettings {
|
|||||||
autoSuspendDays Int @default(30)
|
autoSuspendDays Int @default(30)
|
||||||
/// Days before billing date to generate prepaid invoices (default 7)
|
/// Days before billing date to generate prepaid invoices (default 7)
|
||||||
prepaidLeadDays Int @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())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@ -448,6 +450,8 @@ model User {
|
|||||||
createdExpenses Expense[] @relation("ExpenseCreatedBy")
|
createdExpenses Expense[] @relation("ExpenseCreatedBy")
|
||||||
/// Expenses approved by this user
|
/// Expenses approved by this user
|
||||||
approvedExpenses Expense[] @relation("ExpenseApprovedBy")
|
approvedExpenses Expense[] @relation("ExpenseApprovedBy")
|
||||||
|
/// Ticket comments authored by this user
|
||||||
|
ticketComments TicketComment[]
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
@@ -695,6 +699,27 @@ model Ticket {
|
|||||||
@@index([subscriberId])
|
@@index([subscriberId])
|
||||||
|
|
||||||
jobOrders JobOrder[]
|
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.
|
/// A Collection records cash received from a subscriber by a field collector.
|
||||||
|
|||||||
283
src/lib/services/portal-ticket-service.ts
Normal file
283
src/lib/services/portal-ticket-service.ts
Normal file
@@ -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<string> {
|
||||||
|
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 } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user