feat(03-05): TechnicianProfile/JobTypeRate schema, migration, tenant scoping
- Added CompensationModel enum (PER_JOB, SALARY, HYBRID) - Added TechnicianProfile model with userId FK, skills[], zoneId?, compensationModel, monthlySalary?, isActive, @@unique([tenantId,userId]) - Added JobTypeRate model with jobType, rate, isActive, @@unique([tenantId,jobType]) - Added reverse relations: User.technicianProfiles[], Zone.technicianProfiles[] - Applied migration: 20260305000851_add_technician_profiles - Added technicianProfile and jobTypeRate to TENANT_SCOPED_MODELS with full 12-operation extension blocks
This commit is contained in:
@@ -0,0 +1,51 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "CompensationModel" AS ENUM ('PER_JOB', 'SALARY', 'HYBRID');
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "TechnicianProfile" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"tenantId" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"phone" TEXT,
|
||||||
|
"skills" TEXT[],
|
||||||
|
"zoneId" TEXT,
|
||||||
|
"compensationModel" "CompensationModel" NOT NULL DEFAULT 'PER_JOB',
|
||||||
|
"monthlySalary" DECIMAL(10,2),
|
||||||
|
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "TechnicianProfile_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "JobTypeRate" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"tenantId" TEXT NOT NULL,
|
||||||
|
"jobType" TEXT NOT NULL,
|
||||||
|
"rate" DECIMAL(10,2) NOT NULL,
|
||||||
|
"description" TEXT,
|
||||||
|
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "JobTypeRate_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "TechnicianProfile_tenantId_idx" ON "TechnicianProfile"("tenantId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "TechnicianProfile_tenantId_userId_key" ON "TechnicianProfile"("tenantId", "userId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "JobTypeRate_tenantId_idx" ON "JobTypeRate"("tenantId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "JobTypeRate_tenantId_jobType_key" ON "JobTypeRate"("tenantId", "jobType");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "TechnicianProfile" ADD CONSTRAINT "TechnicianProfile_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "TechnicianProfile" ADD CONSTRAINT "TechnicianProfile_zoneId_fkey" FOREIGN KEY ("zoneId") REFERENCES "Zone"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
@@ -136,6 +136,12 @@ enum JobOrderStatus {
|
|||||||
CANCELLED
|
CANCELLED
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum CompensationModel {
|
||||||
|
PER_JOB
|
||||||
|
SALARY
|
||||||
|
HYBRID
|
||||||
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// MODELS
|
// MODELS
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -320,6 +326,7 @@ model Zone {
|
|||||||
|
|
||||||
subscribers Subscriber[]
|
subscribers Subscriber[]
|
||||||
assignments ZoneAssignment[]
|
assignments ZoneAssignment[]
|
||||||
|
technicianProfiles TechnicianProfile[]
|
||||||
|
|
||||||
/// Zone names must be unique within a tenant
|
/// Zone names must be unique within a tenant
|
||||||
@@unique([tenantId, name])
|
@@unique([tenantId, name])
|
||||||
@@ -392,6 +399,8 @@ model User {
|
|||||||
assignedJobOrders JobOrder[] @relation("JobOrderAssignedTo")
|
assignedJobOrders JobOrder[] @relation("JobOrderAssignedTo")
|
||||||
/// Job orders created by this user (staff)
|
/// Job orders created by this user (staff)
|
||||||
createdJobOrders JobOrder[] @relation("JobOrderCreatedBy")
|
createdJobOrders JobOrder[] @relation("JobOrderCreatedBy")
|
||||||
|
/// Technician profiles for this user (one per tenant, enforced by @@unique([tenantId, userId]))
|
||||||
|
technicianProfiles TechnicianProfile[]
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
@@ -795,3 +804,54 @@ model InvoiceLine {
|
|||||||
@@index([invoiceId])
|
@@index([invoiceId])
|
||||||
@@index([tenantId])
|
@@index([tenantId])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A TechnicianProfile holds ISP-specific attributes for a technician user.
|
||||||
|
/// Compensation model determines how the technician is paid:
|
||||||
|
/// PER_JOB: sum of job type rates for completed jobs
|
||||||
|
/// SALARY: fixed monthly salary only
|
||||||
|
/// HYBRID: monthly salary + per-job bonuses
|
||||||
|
model TechnicianProfile {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
tenantId String
|
||||||
|
/// The technician user (FK to User) — one profile per user per tenant
|
||||||
|
userId String
|
||||||
|
user User @relation(fields: [userId], references: [id])
|
||||||
|
phone String?
|
||||||
|
/// PostgreSQL array of skill tags (e.g., ["fiber", "wireless", "installation"])
|
||||||
|
skills String[]
|
||||||
|
/// Zone the technician is primarily assigned to (optional)
|
||||||
|
zoneId String?
|
||||||
|
zone Zone? @relation(fields: [zoneId], references: [id])
|
||||||
|
compensationModel CompensationModel @default(PER_JOB)
|
||||||
|
/// Fixed monthly salary component (used for SALARY and HYBRID models)
|
||||||
|
monthlySalary Decimal? @db.Decimal(10, 2)
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
/// One profile per user per tenant
|
||||||
|
@@unique([tenantId, userId])
|
||||||
|
/// RLS-ready index — always present on tenant-scoped models
|
||||||
|
@@index([tenantId])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A JobTypeRate defines the per-job bonus rate for a specific job type at tenant level.
|
||||||
|
/// Used to calculate per-job compensation for technicians (PER_JOB and HYBRID models).
|
||||||
|
/// Missing rates default to 0 bonus (not an error).
|
||||||
|
model JobTypeRate {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
tenantId String
|
||||||
|
/// The job type identifier (e.g., "Installation", "Repair", "Maintenance")
|
||||||
|
jobType String
|
||||||
|
/// Per-job bonus rate for this job type
|
||||||
|
rate Decimal @db.Decimal(10, 2)
|
||||||
|
description String?
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
/// One rate per job type per tenant
|
||||||
|
@@unique([tenantId, jobType])
|
||||||
|
/// RLS-ready index — always present on tenant-scoped models
|
||||||
|
@@index([tenantId])
|
||||||
|
}
|
||||||
|
|||||||
@@ -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", "ticket", "ticketCategory", "collection", "collectionAllocation", "remittance", "jobOrder"] 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", "technicianProfile", "jobTypeRate"] as const;
|
||||||
|
|
||||||
export type TenantScopedModel = (typeof TENANT_SCOPED_MODELS)[number];
|
export type TenantScopedModel = (typeof TENANT_SCOPED_MODELS)[number];
|
||||||
|
|
||||||
@@ -1704,6 +1704,198 @@ export function withTenantContext(tenantId: string) {
|
|||||||
return query(args);
|
return query(args);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
technicianProfile: {
|
||||||
|
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.technicianProfile.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.technicianProfile.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);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
jobTypeRate: {
|
||||||
|
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.jobTypeRate.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.jobTypeRate.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