feat(03-03): Ticket schema, categories, migration, and tenant scoping
- Added TicketStatus, TicketPriority, TicketSource enums to schema - Added TicketCategory model (tenant-scoped, unique per tenant by name) - Added Ticket model with lifecycle fields (status, priority, source, resolvedAt, closedAt) - Added reverse relations: Subscriber.tickets, User.createdTickets, TicketCategory.tickets - Applied migration 20260304233528_add_tickets - Added ticket and ticketCategory to TENANT_SCOPED_MODELS with full extension blocks - Seeded 6 default ISP ticket categories in createTenant transaction
This commit is contained in:
73
prisma/migrations/20260304233528_add_tickets/migration.sql
Normal file
73
prisma/migrations/20260304233528_add_tickets/migration.sql
Normal file
@@ -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;
|
||||||
@@ -100,6 +100,25 @@ enum PaymentStatus {
|
|||||||
VOIDED
|
VOIDED
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum TicketStatus {
|
||||||
|
OPEN
|
||||||
|
ASSIGNED
|
||||||
|
RESOLVED
|
||||||
|
CLOSED
|
||||||
|
}
|
||||||
|
|
||||||
|
enum TicketPriority {
|
||||||
|
LOW
|
||||||
|
MEDIUM
|
||||||
|
HIGH
|
||||||
|
URGENT
|
||||||
|
}
|
||||||
|
|
||||||
|
enum TicketSource {
|
||||||
|
STAFF
|
||||||
|
SUBSCRIBER
|
||||||
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// MODELS
|
// MODELS
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -258,6 +277,7 @@ model Subscriber {
|
|||||||
|
|
||||||
invoices Invoice[]
|
invoices Invoice[]
|
||||||
payments Payment[]
|
payments Payment[]
|
||||||
|
tickets Ticket[]
|
||||||
|
|
||||||
/// Account numbers must be unique within a tenant
|
/// Account numbers must be unique within a tenant
|
||||||
@@unique([tenantId, accountNumber])
|
@@unique([tenantId, accountNumber])
|
||||||
@@ -340,6 +360,8 @@ model User {
|
|||||||
recordedPayments Payment[] @relation("PaymentRecordedBy")
|
recordedPayments Payment[] @relation("PaymentRecordedBy")
|
||||||
/// Zone assignments for collector role (which zones this user can collect from)
|
/// Zone assignments for collector role (which zones this user can collect from)
|
||||||
zoneAssignments ZoneAssignment[]
|
zoneAssignments ZoneAssignment[]
|
||||||
|
/// Tickets this user created (staff or subscriber portal)
|
||||||
|
createdTickets Ticket[] @relation("TicketCreatedBy")
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
@@ -529,6 +551,63 @@ model PaymentAllocation {
|
|||||||
@@index([tenantId])
|
@@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").
|
/// A single line item on an Invoice (e.g., "50 Mbps Monthly Service — $49.99").
|
||||||
model InvoiceLine {
|
model InvoiceLine {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ import { prisma } from "@/lib/prisma";
|
|||||||
* Extend this list as new models are added in later phases:
|
* Extend this list as new models are added in later phases:
|
||||||
* e.g., "subscriber", "invoice", "servicePlan", "payment"
|
* 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];
|
export type TenantScopedModel = (typeof TENANT_SCOPED_MODELS)[number];
|
||||||
|
|
||||||
@@ -1128,6 +1128,198 @@ export function withTenantContext(tenantId: string) {
|
|||||||
return query(args);
|
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);
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -198,6 +198,18 @@ export async function createTenant(input: CreateTenantInput): Promise<CreateTena
|
|||||||
// if anything fails, neither the tenant, user, nor COA are persisted.
|
// if anything fails, neither the tenant, user, nor COA are persisted.
|
||||||
await seedChartOfAccounts(tx, tenant.id);
|
await seedChartOfAccounts(tx, tenant.id);
|
||||||
|
|
||||||
|
// Seed default ISP ticket categories for this tenant.
|
||||||
|
// These provide a sensible starting set — admins can add/deactivate later.
|
||||||
|
const defaultCategories = [
|
||||||
|
{ name: "No Connection", description: "Subscriber has no internet connection", tenantId: tenant.id },
|
||||||
|
{ name: "Slow Speed", description: "Connection speed below expected plan speed", tenantId: tenant.id },
|
||||||
|
{ name: "Billing Inquiry", description: "Questions about bills or payments", tenantId: tenant.id },
|
||||||
|
{ name: "New Installation", description: "Request for new service installation", tenantId: tenant.id },
|
||||||
|
{ name: "Equipment Issue", description: "Router, ONU, or cable problems", tenantId: tenant.id },
|
||||||
|
{ name: "Other", description: "Other issues not covered by categories above", tenantId: tenant.id },
|
||||||
|
];
|
||||||
|
await tx.ticketCategory.createMany({ data: defaultCategories });
|
||||||
|
|
||||||
return { tenant, user };
|
return { tenant, user };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user