feat(03-04): JobOrder schema, migration, and tenant scoping
- Added JobOrderStatus enum (PENDING, IN_PROGRESS, COMPLETED, CANCELLED) - Added JobOrder model with ticket FK, assignedTo/createdBy user FKs - Added orderNumber (JO-NNNN), scheduledDate, timestamps, outcomeNotes - Added jobOrders reverse relation on Ticket model - Added assignedJobOrders/createdJobOrders reverse relations on User model - Applied migration: 20260304235859_add_job_orders - Added jobOrder to TENANT_SCOPED_MODELS with full 12-operation extension block
This commit is contained in:
@@ -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;
|
||||
@@ -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())
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user