diff --git a/prisma/migrations/20260304235859_add_job_orders/migration.sql b/prisma/migrations/20260304235859_add_job_orders/migration.sql new file mode 100644 index 0000000..bd296e1 --- /dev/null +++ b/prisma/migrations/20260304235859_add_job_orders/migration.sql @@ -0,0 +1,49 @@ +-- CreateEnum +CREATE TYPE "JobOrderStatus" AS ENUM ('PENDING', 'IN_PROGRESS', 'COMPLETED', 'CANCELLED'); + +-- CreateTable +CREATE TABLE "JobOrder" ( + "id" TEXT NOT NULL, + "tenantId" TEXT NOT NULL, + "orderNumber" TEXT NOT NULL, + "ticketId" TEXT NOT NULL, + "jobType" TEXT NOT NULL, + "description" TEXT, + "assignedToId" TEXT NOT NULL, + "status" "JobOrderStatus" NOT NULL DEFAULT 'PENDING', + "scheduledDate" TIMESTAMP(3), + "startedAt" TIMESTAMP(3), + "completedAt" TIMESTAMP(3), + "outcomeNotes" TEXT, + "cancelledAt" TIMESTAMP(3), + "cancelReason" TEXT, + "createdById" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "JobOrder_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "JobOrder_tenantId_idx" ON "JobOrder"("tenantId"); + +-- CreateIndex +CREATE INDEX "JobOrder_tenantId_ticketId_idx" ON "JobOrder"("tenantId", "ticketId"); + +-- CreateIndex +CREATE INDEX "JobOrder_tenantId_assignedToId_idx" ON "JobOrder"("tenantId", "assignedToId"); + +-- CreateIndex +CREATE INDEX "JobOrder_tenantId_status_idx" ON "JobOrder"("tenantId", "status"); + +-- CreateIndex +CREATE UNIQUE INDEX "JobOrder_tenantId_orderNumber_key" ON "JobOrder"("tenantId", "orderNumber"); + +-- AddForeignKey +ALTER TABLE "JobOrder" ADD CONSTRAINT "JobOrder_ticketId_fkey" FOREIGN KEY ("ticketId") REFERENCES "Ticket"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "JobOrder" ADD CONSTRAINT "JobOrder_assignedToId_fkey" FOREIGN KEY ("assignedToId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "JobOrder" ADD CONSTRAINT "JobOrder_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 d3853d8..d579dfb 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -129,6 +129,13 @@ enum RemittanceStatus { VERIFIED } +enum JobOrderStatus { + PENDING + IN_PROGRESS + COMPLETED + CANCELLED +} + // ============================================================================= // MODELS // ============================================================================= @@ -381,6 +388,10 @@ model User { remittances Remittance[] @relation("RemittanceByCollector") /// Remittances verified by this user (office staff) verifiedRemittances Remittance[] @relation("RemittanceVerifiedBy") + /// Job orders assigned to this technician + assignedJobOrders JobOrder[] @relation("JobOrderAssignedTo") + /// Job orders created by this user (staff) + createdJobOrders JobOrder[] @relation("JobOrderCreatedBy") createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -626,6 +637,8 @@ model Ticket { @@index([tenantId, status]) @@index([tenantId, categoryId]) @@index([subscriberId]) + + jobOrders JobOrder[] } /// A Collection records cash received from a subscriber by a field collector. @@ -722,6 +735,51 @@ model Remittance { @@index([tenantId, status]) } +/// A JobOrder is the execution unit created from a support ticket. +/// One ticket can have many job orders (1:many). +/// A technician is assigned to each job order and updates its status. +/// When ALL non-cancelled job orders on a ticket are COMPLETED, the ticket auto-resolves. +/// When ALL job orders on a ticket are CANCELLED, the ticket reverts to OPEN. +model JobOrder { + id String @id @default(uuid()) + tenantId String + /// Auto-generated sequential identifier per tenant (e.g., "JO-0001") + orderNumber String + /// The ticket this job order is for + ticketId String + ticket Ticket @relation(fields: [ticketId], references: [id]) + /// Type of work (e.g., "Installation", "Repair", "Maintenance") + jobType String + description String? + /// The technician assigned to this job order + assignedToId String + assignedTo User @relation("JobOrderAssignedTo", fields: [assignedToId], references: [id]) + status JobOrderStatus @default(PENDING) + scheduledDate DateTime? + /// Timestamp when the technician started working + startedAt DateTime? + /// Timestamp when the job was completed + completedAt DateTime? + /// Notes on outcome, required when marking COMPLETED + outcomeNotes String? + /// Timestamp when the job was cancelled + cancelledAt DateTime? + cancelReason String? + /// The staff member who created this job order + createdById String + createdBy User @relation("JobOrderCreatedBy", fields: [createdById], references: [id]) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + /// Order numbers must be unique within a tenant + @@unique([tenantId, orderNumber]) + /// RLS-ready index — always present on tenant-scoped models + @@index([tenantId]) + @@index([tenantId, ticketId]) + @@index([tenantId, assignedToId]) + @@index([tenantId, status]) +} + /// 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 a77e3df..50065c1 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", "ticket", "ticketCategory", "collection", "collectionAllocation", "remittance"] as const; +export const TENANT_SCOPED_MODELS = ["user", "account", "accountingPeriod", "subscriber", "servicePlan", "tenantSettings", "journalEntry", "journalEntryLine", "invoice", "invoiceLine", "payment", "paymentAllocation", "zone", "zoneAssignment", "ticket", "ticketCategory", "collection", "collectionAllocation", "remittance", "jobOrder"] as const; export type TenantScopedModel = (typeof TENANT_SCOPED_MODELS)[number]; @@ -1608,6 +1608,102 @@ export function withTenantContext(tenantId: string) { return query(args); }, }, + + jobOrder: { + 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.jobOrder.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.jobOrder.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); + }, + }, }, }); }