diff --git a/prisma/migrations/20260304233528_add_tickets/migration.sql b/prisma/migrations/20260304233528_add_tickets/migration.sql new file mode 100644 index 0000000..a758db2 --- /dev/null +++ b/prisma/migrations/20260304233528_add_tickets/migration.sql @@ -0,0 +1,73 @@ +-- CreateEnum +CREATE TYPE "TicketStatus" AS ENUM ('OPEN', 'ASSIGNED', 'RESOLVED', 'CLOSED'); + +-- CreateEnum +CREATE TYPE "TicketPriority" AS ENUM ('LOW', 'MEDIUM', 'HIGH', 'URGENT'); + +-- CreateEnum +CREATE TYPE "TicketSource" AS ENUM ('STAFF', 'SUBSCRIBER'); + +-- CreateTable +CREATE TABLE "TicketCategory" ( + "id" TEXT NOT NULL, + "tenantId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT, + "isActive" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "TicketCategory_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Ticket" ( + "id" TEXT NOT NULL, + "tenantId" TEXT NOT NULL, + "ticketNumber" TEXT NOT NULL, + "subject" TEXT NOT NULL, + "description" TEXT NOT NULL, + "categoryId" TEXT NOT NULL, + "priority" "TicketPriority" NOT NULL DEFAULT 'MEDIUM', + "status" "TicketStatus" NOT NULL DEFAULT 'OPEN', + "source" "TicketSource" NOT NULL DEFAULT 'STAFF', + "subscriberId" TEXT, + "createdById" TEXT NOT NULL, + "resolvedAt" TIMESTAMP(3), + "closedAt" TIMESTAMP(3), + "notes" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Ticket_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "TicketCategory_tenantId_idx" ON "TicketCategory"("tenantId"); + +-- CreateIndex +CREATE UNIQUE INDEX "TicketCategory_tenantId_name_key" ON "TicketCategory"("tenantId", "name"); + +-- CreateIndex +CREATE INDEX "Ticket_tenantId_idx" ON "Ticket"("tenantId"); + +-- CreateIndex +CREATE INDEX "Ticket_tenantId_status_idx" ON "Ticket"("tenantId", "status"); + +-- CreateIndex +CREATE INDEX "Ticket_tenantId_categoryId_idx" ON "Ticket"("tenantId", "categoryId"); + +-- CreateIndex +CREATE INDEX "Ticket_subscriberId_idx" ON "Ticket"("subscriberId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Ticket_tenantId_ticketNumber_key" ON "Ticket"("tenantId", "ticketNumber"); + +-- AddForeignKey +ALTER TABLE "Ticket" ADD CONSTRAINT "Ticket_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "TicketCategory"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Ticket" ADD CONSTRAINT "Ticket_subscriberId_fkey" FOREIGN KEY ("subscriberId") REFERENCES "Subscriber"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Ticket" ADD CONSTRAINT "Ticket_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 016e60c..b0c5237 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -100,6 +100,25 @@ enum PaymentStatus { VOIDED } +enum TicketStatus { + OPEN + ASSIGNED + RESOLVED + CLOSED +} + +enum TicketPriority { + LOW + MEDIUM + HIGH + URGENT +} + +enum TicketSource { + STAFF + SUBSCRIBER +} + // ============================================================================= // MODELS // ============================================================================= @@ -258,6 +277,7 @@ model Subscriber { invoices Invoice[] payments Payment[] + tickets Ticket[] /// Account numbers must be unique within a tenant @@unique([tenantId, accountNumber]) @@ -340,6 +360,8 @@ model User { recordedPayments Payment[] @relation("PaymentRecordedBy") /// Zone assignments for collector role (which zones this user can collect from) zoneAssignments ZoneAssignment[] + /// Tickets this user created (staff or subscriber portal) + createdTickets Ticket[] @relation("TicketCreatedBy") createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -529,6 +551,63 @@ model PaymentAllocation { @@index([tenantId]) } +/// A TicketCategory is an admin-configurable label for classifying support tickets. +/// Default ISP categories are seeded at tenant creation. +/// Deactivated categories (isActive=false) cannot be used for new tickets. +model TicketCategory { + id String @id @default(uuid()) + tenantId String + name String + description String? + isActive Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + tickets Ticket[] + + /// Category names must be unique within a tenant + @@unique([tenantId, name]) + /// RLS-ready index — always present on tenant-scoped models + @@index([tenantId]) +} + +/// A Ticket records a support request from a subscriber or staff member. +/// Tickets follow the lifecycle: OPEN -> ASSIGNED -> RESOLVED -> CLOSED. +/// Status transitions are validated by the ticket service guard map. +model Ticket { + id String @id @default(uuid()) + tenantId String + /// Auto-generated sequential identifier per tenant (e.g., "TKT-0001") + ticketNumber String + subject String + description String + categoryId String + category TicketCategory @relation(fields: [categoryId], references: [id]) + priority TicketPriority @default(MEDIUM) + status TicketStatus @default(OPEN) + source TicketSource @default(STAFF) + /// The subscriber this ticket is about (optional — not all tickets are subscriber-specific) + subscriberId String? + subscriber Subscriber? @relation(fields: [subscriberId], references: [id]) + /// The staff member or subscriber who created this ticket + createdById String + createdBy User @relation("TicketCreatedBy", fields: [createdById], references: [id]) + resolvedAt DateTime? + closedAt DateTime? + /// Internal staff notes + notes String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + /// Ticket numbers must be unique within a tenant + @@unique([tenantId, ticketNumber]) + /// RLS-ready index — always present on tenant-scoped models + @@index([tenantId]) + @@index([tenantId, status]) + @@index([tenantId, categoryId]) + @@index([subscriberId]) +} + /// A single line item on an Invoice (e.g., "50 Mbps Monthly Service — $49.99"). model InvoiceLine { id String @id @default(uuid()) diff --git a/src/lib/prisma-tenant.ts b/src/lib/prisma-tenant.ts index b241944..376425f 100644 --- a/src/lib/prisma-tenant.ts +++ b/src/lib/prisma-tenant.ts @@ -30,7 +30,7 @@ import { prisma } from "@/lib/prisma"; * Extend this list as new models are added in later phases: * e.g., "subscriber", "invoice", "servicePlan", "payment" */ -export const TENANT_SCOPED_MODELS = ["user", "account", "accountingPeriod", "subscriber", "servicePlan", "tenantSettings", "journalEntry", "journalEntryLine", "invoice", "invoiceLine", "payment", "paymentAllocation", "zone", "zoneAssignment"] as const; +export const TENANT_SCOPED_MODELS = ["user", "account", "accountingPeriod", "subscriber", "servicePlan", "tenantSettings", "journalEntry", "journalEntryLine", "invoice", "invoiceLine", "payment", "paymentAllocation", "zone", "zoneAssignment", "ticket", "ticketCategory"] as const; export type TenantScopedModel = (typeof TENANT_SCOPED_MODELS)[number]; @@ -1128,6 +1128,198 @@ export function withTenantContext(tenantId: string) { return query(args); }, }, + + ticketCategory: { + async findMany({ args, query }) { + args.where = { ...args.where, tenantId }; + return query(args); + }, + + async findFirst({ args, query }) { + args.where = { ...args.where, tenantId }; + return query(args); + }, + + async findFirstOrThrow({ args, query }) { + args.where = { ...args.where, tenantId }; + return query(args); + }, + + async findUnique({ args, query }) { + if (args.where && "id" in args.where && !("tenantId" in (args.where as object))) { + return prisma.ticketCategory.findFirst({ + ...args, + where: { ...args.where, tenantId }, + }); + } + return query(args); + }, + + async findUniqueOrThrow({ args, query }) { + if (args.where && "id" in args.where && !("tenantId" in (args.where as object))) { + const result = await prisma.ticketCategory.findFirst({ + ...args, + where: { ...args.where, tenantId }, + }); + if (!result) { + throw new Error("Record not found"); + } + return result; + } + return query(args); + }, + + async create({ args, query }) { + args.data = { ...args.data, tenantId } as typeof args.data; + return query(args); + }, + + async createMany({ args, query }) { + if (Array.isArray(args.data)) { + args.data = args.data.map((item) => ({ ...item, tenantId })) as typeof args.data; + } else { + args.data = { ...args.data, tenantId } as typeof args.data; + } + return query(args); + }, + + async update({ args, query }) { + args.where = { ...args.where, tenantId }; + return query(args); + }, + + async updateMany({ args, query }) { + args.where = { ...args.where, tenantId }; + return query(args); + }, + + async delete({ args, query }) { + args.where = { ...args.where, tenantId }; + return query(args); + }, + + async deleteMany({ args, query }) { + args.where = { ...args.where, tenantId }; + return query(args); + }, + + async upsert({ args, query }) { + args.where = { ...args.where, tenantId } as typeof args.where; + args.create = { ...args.create, tenantId } as typeof args.create; + return query(args); + }, + + async count({ args, query }) { + args.where = { ...args.where, tenantId }; + return query(args); + }, + + async aggregate({ args, query }) { + args.where = { ...args.where, tenantId }; + return query(args); + }, + + async groupBy({ args, query }) { + args.where = { ...args.where, tenantId }; + return query(args); + }, + }, + + ticket: { + async findMany({ args, query }) { + args.where = { ...args.where, tenantId }; + return query(args); + }, + + async findFirst({ args, query }) { + args.where = { ...args.where, tenantId }; + return query(args); + }, + + async findFirstOrThrow({ args, query }) { + args.where = { ...args.where, tenantId }; + return query(args); + }, + + async findUnique({ args, query }) { + if (args.where && "id" in args.where && !("tenantId" in (args.where as object))) { + return prisma.ticket.findFirst({ + ...args, + where: { ...args.where, tenantId }, + }); + } + return query(args); + }, + + async findUniqueOrThrow({ args, query }) { + if (args.where && "id" in args.where && !("tenantId" in (args.where as object))) { + const result = await prisma.ticket.findFirst({ + ...args, + where: { ...args.where, tenantId }, + }); + if (!result) { + throw new Error("Record not found"); + } + return result; + } + return query(args); + }, + + async create({ args, query }) { + args.data = { ...args.data, tenantId } as typeof args.data; + return query(args); + }, + + async createMany({ args, query }) { + if (Array.isArray(args.data)) { + args.data = args.data.map((item) => ({ ...item, tenantId })) as typeof args.data; + } else { + args.data = { ...args.data, tenantId } as typeof args.data; + } + return query(args); + }, + + async update({ args, query }) { + args.where = { ...args.where, tenantId }; + return query(args); + }, + + async updateMany({ args, query }) { + args.where = { ...args.where, tenantId }; + return query(args); + }, + + async delete({ args, query }) { + args.where = { ...args.where, tenantId }; + return query(args); + }, + + async deleteMany({ args, query }) { + args.where = { ...args.where, tenantId }; + return query(args); + }, + + async upsert({ args, query }) { + args.where = { ...args.where, tenantId } as typeof args.where; + args.create = { ...args.create, tenantId } as typeof args.create; + return query(args); + }, + + async count({ args, query }) { + args.where = { ...args.where, tenantId }; + return query(args); + }, + + async aggregate({ args, query }) { + args.where = { ...args.where, tenantId }; + return query(args); + }, + + async groupBy({ args, query }) { + args.where = { ...args.where, tenantId }; + return query(args); + }, + }, }, }); } diff --git a/src/lib/tenant.ts b/src/lib/tenant.ts index d75d00d..45d3da7 100644 --- a/src/lib/tenant.ts +++ b/src/lib/tenant.ts @@ -198,6 +198,18 @@ export async function createTenant(input: CreateTenantInput): Promise