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:
kevin-asprec
2026-03-05 17:33:13 +08:00
parent ab1e94480e
commit 2d5b9ca2aa
2 changed files with 308 additions and 0 deletions

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